support_desk 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +1131 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +21 -3
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  30. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  31. data/lib/generators/support_desk/upgrade_generator.rb +25 -7
  32. data/lib/support_desk/assistant_policy.rb +213 -0
  33. data/lib/support_desk/brief.rb +283 -0
  34. data/lib/support_desk/configuration.rb +552 -2
  35. data/lib/support_desk/console.rb +290 -8
  36. data/lib/support_desk/context_card.rb +10 -1
  37. data/lib/support_desk/doctor.rb +171 -1
  38. data/lib/support_desk/engine.rb +16 -1
  39. data/lib/support_desk/errors.rb +37 -0
  40. data/lib/support_desk/events.rb +12 -5
  41. data/lib/support_desk/macros.rb +14 -1
  42. data/lib/support_desk/message_signatures.rb +20 -0
  43. data/lib/support_desk/models/assistant.rb +188 -0
  44. data/lib/support_desk/models/concerns/requester.rb +18 -0
  45. data/lib/support_desk/models/desk.rb +26 -3
  46. data/lib/support_desk/models/draft.rb +275 -0
  47. data/lib/support_desk/models/event.rb +15 -1
  48. data/lib/support_desk/models/message_registration.rb +12 -0
  49. data/lib/support_desk/models/ticket/assistance.rb +899 -0
  50. data/lib/support_desk/models/ticket.rb +406 -58
  51. data/lib/support_desk/outcome.rb +38 -0
  52. data/lib/support_desk/queue.rb +41 -5
  53. data/lib/support_desk/test_helpers.rb +152 -0
  54. data/lib/support_desk/timeline.rb +40 -11
  55. data/lib/support_desk/topic.rb +22 -0
  56. data/lib/support_desk/topic_tree.rb +7 -1
  57. data/lib/support_desk/transcript.rb +234 -0
  58. data/lib/support_desk/version.rb +1 -1
  59. data/lib/support_desk.rb +151 -0
  60. data/lib/tasks/support_desk.rake +57 -0
  61. metadata +33 -8
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🎫 `support_desk` - Customer support for your Rails app, as conversations
1
+ # 🎫 `support_desk` - Customer support for your Rails app, as conversations — answered by your team and by AI agents on a leash
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/support_desk.svg)](https://badge.fury.io/rb/support_desk) [![Build Status](https://github.com/rameerez/support_desk/workflows/Tests/badge.svg)](https://github.com/rameerez/support_desk/actions)
4
4
 
@@ -7,6 +7,8 @@
7
7
 
8
8
  `support_desk` gives your Rails app a **support desk**: tickets that are real conversations. Somebody asks for help about something in your app (a ride, an order, a withdrawal) or about nothing in particular, your desk answers, humans sign the answers, and your team works a queue.
9
9
 
10
+ **AI agents are first-class citizens of that desk.** An assistant is an agent with a policy: a seat, a name, a turn budget and a level (`observe · draft · reply · resolve`) that says what she may *produce* on a case. At the default level she proposes and a person sends, signed by them; raise her level per topic and she answers customers herself, hands off when she is unsure, and can never touch a case a human holds, a money topic you capped, or a customer who asked for a person. The gem ships the guardrails — policy, drafts and review, the turn that makes a late model answer harmless, disclosure, the two exits, the sweep that catches a dead harness — and **no LLM**: bring any provider, any prompt, any retrieval, in a job of about ten lines. [→ Assistants](#-assistants)
11
+
10
12
  Here is the whole thing — the kind of support desk a DoorDash, an Uber Eats or a Grab needs — running on a made-up delivery app called Pepperbox. These are the **bundled views**, unmodified, themed by the host with a handful of CSS variables:
11
13
 
12
14
  | One row, every case | Pick a topic | Which order? |
@@ -19,7 +21,19 @@ Here is the whole thing — the kind of support desk a DoorDash, an Uber Eats or
19
21
 
20
22
  It is a product gem on the [`chats`](https://github.com/rameerez/chats) kernel: chats owns the transcript, realtime, attachments, read state and moderation; `support_desk` owns the case — topics, assignment, SLA clocks, events and the console API.
21
23
 
22
- Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
24
+ Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge — and, lately, the same "let the model answer, but not *that*" rules. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
25
+
26
+ What "AI-native" means here, concretely:
27
+
28
+ - **One verb for the harness.** `ticket.respond!(text, by: rose, turn:)` — policy decides whether it is sent, drafted for a person, or withheld with the reason on the record. The job never encodes the rules.
29
+ - **Bounded authority, in the model.** Levels, topic caps, a per-case cap, pause, and floors for closed / human-held / human-requested cases — enforced inside every transition, not in a prompt or a button.
30
+ - **A turn, not a lock-free hope.** Every message and transition moves `assistant_turn`; every assistant action requires and consumes it, so a late, retried or redelivered job writes nothing.
31
+ - **Human in the loop by default.** Proposals a person sends verbatim or edited — as *their* message — or rejects with a reason you can raise her level on.
32
+ - **Two exits, always.** She escalates; the customer has a door to a person that never disappears.
33
+ - **Disclosure is your explicit choice**, and the record tells the truth in every mode.
34
+ - **Context as data.** `ticket.brief` and `ticket.transcript` — facts, roles, `may` / `may_not` — for any provider.
35
+
36
+ **Contents:** [Example](#-example) · [Quickstart](#quickstart) · [Configuration reference](#configuration-reference) · [Topics](#topics) · [Model macros](#the-model-macros) · [Tickets](#tickets) · [Queues and presenters](#queues-and-presenters) · [The requester experience](#the-requester-experience) · [The agent console](#the-agent-console) · [Writing first](#writing-first) · [Assistants](#-assistants) · [The wizard](#the-wizard) · [Events](#events) · [Errors](#errors) · [Locales](#locales) · [Doctor](#doctor) · [Compatibility](#compatibility) · [Testing](#testing) · [Module-level API](#module-level-api)
23
37
 
24
38
  ## 👨‍💻 Example
25
39
 
@@ -42,9 +56,14 @@ ticket.reply!("We're on it", by: lucia) # you answ
42
56
  ticket.close!(by: lucia)
43
57
 
44
58
  lucia.open_support_conversation_with!(alice, "We saw your refund bounced", about: order) # you write first
59
+
60
+ config.assistant(:rose) { |rose| rose.autonomy = :draft; rose.disclosure = :signature } # an AI agent, on a leash
61
+ rose = SupportDesk.assistant(:rose)
62
+ ticket.respond!(answer_from_your_model, by: rose, turn: ticket.assistant_turn) # she proposes; policy decides
63
+ ticket.pending_draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # a person sends it, signed by them
45
64
  ```
46
65
 
47
- That's a ticket, a conversation, an assignment history, an append-only audit trail and four events your app can subscribe to.
66
+ That's a ticket, a conversation, an assignment history, an append-only audit trail, an AI agent whose every action is policy-checked under the row lock, and a dozen events your app can subscribe to.
48
67
 
49
68
  ## Quickstart
50
69
 
@@ -69,6 +88,9 @@ workers, and backfill before starting 0.2 traffic. This is not a rolling
69
88
  upgrade: old assignment writers cannot handle new support-opened cases.
70
89
  See the CHANGELOG for the complete cutover and rollback procedure.
71
90
 
91
+ Already on 0.2? The same generator copies the 0.3 assistants migration,
92
+ which is additive and needs no drain — see [Assistants](#-assistants).
93
+
72
94
  Three model lines and one route line:
73
95
 
74
96
  ```ruby
@@ -110,6 +132,129 @@ Check your work any time with `SupportDesk.doctor.print`.
110
132
 
111
133
  Desk records are memoised for the life of the process, so anything that has to change everywhere at once belongs in this initializer rather than in a desk's `settings` column.
112
134
 
135
+ ## Configuration reference
136
+
137
+ Everything lives in `config/initializers/support_desk.rb` (the install
138
+ generator writes an annotated one). Two rules, shared with the rest of the
139
+ gem ecosystem: class names are stored as **strings** and constantized lazily,
140
+ so the initializer can name app classes before they load and everything
141
+ survives reloads; and every setter **validates on assignment** and raises
142
+ `SupportDesk::ConfigurationError` with the fix in the message — a
143
+ configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
144
+
145
+ ### Installation settings
146
+
147
+ | setting | default | what it decides |
148
+ |---|---|---|
149
+ | `requester_class` | `"User"` | the model with `has_support_tickets`; it must also be a chats messager |
150
+ | `parent_controller` | `"::ApplicationController"` | what the requester-facing engine inherits: your layout, auth, helpers, locale |
151
+ | `console_parent_controller` | your admin's base controller | what the mounted `ConsoleEngine` and the generated console inherit |
152
+ | `current_requester_method` | `:current_user` | how the engine finds the person asking |
153
+ | `current_agent_method` | `:current_user` | how the console finds the person answering (or define `current_agent` in your controller) |
154
+ | `authenticate_method` | `:authenticate_user!` | your own filter, run before every requester screen |
155
+ | `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
156
+ | `authorize_console` | `nil` (allow) | `->(agent, ticket, action) { … }`; asked before every console action, `ticket` is nil for `index`, `new`, `next` and `open_conversation`; a hook that raises **denies** |
157
+ | `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
158
+ | `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
159
+ | `on(event) { … }` | — | subscribe to an event (see [Events](#events)); pass `key:` from code that reloads |
160
+
161
+ ### Desk settings
162
+
163
+ Top-level setters configure the `:default` desk. Every other desk inherits
164
+ whatever it does not state:
165
+
166
+ ```ruby
167
+ config.desk :billing do |desk|
168
+ desk.name = "Billing"
169
+ desk.reply_within = 8.hours
170
+ desk.topics { topic :invoice, about: Invoice; other }
171
+ end
172
+
173
+ SupportDesk.desk # the :default Desk record, memoised for the process
174
+ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
175
+ ```
176
+
177
+ | setting | default | values / meaning |
178
+ |---|---|---|
179
+ | `name` | the key, humanized | what requesters see as the counterpart |
180
+ | `avatar` | `nil` | anything `image_tag` takes, or `->(desk) { … }` |
181
+ | `email` | `nil` | the address the email channel will answer from (channel lands in a later release) |
182
+ | `agents { … }` | — | a block or lambda returning the agent pool: notified while a case is unheld, offered in the assign picker |
183
+ | `assistant` | the default one | the key of the assistant that works this desk; an explicit `nil` **disables** her here rather than inheriting (see [Assistants](#-assistants)) |
184
+ | `topics do … end` | `other` only | the topic tree (see [Topics](#topics)) |
185
+ | `reply_policy` | `:anyone` | `:anyone` (a drop-in posts, signed; an unheld case is taken by whoever answers) · `:take_over` (replying reassigns) · `:assignee_only` (raises `NotAllowed`) |
186
+ | `announce_assignments` | `:first_only` | `:first_only` ("Lucía is taking care of your request" once) · `:always` (hand-offs too) · `:never` |
187
+ | `closed_tickets` | `:reopen_on_reply` | `:reopen_on_reply` (a requester writing reopens the case) · `:locked` (composer replaced by a notice) |
188
+ | `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads |
189
+ | `at_risk_after` | `4.hours` | when a waiting case starts showing as at risk |
190
+ | `open_rate_limit` | `{ to: 5, within: 1.hour }` | how often one requester may **ask**; `nil` disables. Counts only cases the requester opened |
191
+ | `max_open_tickets` | `5` | how many cases one requester may have open; `nil` disables. Same rule |
192
+ | `inbox_entry` | `:always` | when the desk shows in an inbox with no cases yet: `:always` · `:when_tickets` · `:never` |
193
+ | `routing` | `:manual` | new cases are unassigned and the first "take" wins (`:round_robin` / `:least_loaded` are reserved and refused until they ship) |
194
+ | `mirror_replies_by_email` | `:when_away` | reserved for the email channel: `:always` · `:when_away` · `:never` |
195
+ | `auto_close_after` | `nil` | reserved for the sweep job: a duration after which an answered case closes itself |
196
+ | `opening_line` | `nil` | the system line a requester-opened thread starts with (see [Writing first](#writing-first)) |
197
+ | `opening_line_from_support` | the gem's I18n copy | the same line for a case the desk opened |
198
+ | `find_requester { \|query\| … }` | `nil` | how a console turns what an agent typed into a requester record |
199
+
200
+ `SupportDesk.doctor.print` checks all of it against a running app (see
201
+ [Doctor](#doctor)).
202
+
203
+ ## Topics
204
+
205
+ Topics are a **tree defined in code** and stored on the ticket as a stable
206
+ path (`"billing/invoice"`), so they can carry behaviour — which models they
207
+ attach to, which picker, which desk — and be reviewed and versioned like
208
+ everything else. Labels are I18n keys by default, so copy is a locale edit.
209
+
210
+ ```ruby
211
+ config.topics do
212
+ topic :order, about: Order, subject: :required, icon: "package"
213
+ topic :billing, priority: :high do
214
+ topic :invoice, about: Invoice, ask: "Which invoice?"
215
+ topic :refund, prefill: "Hi, I'd like a refund for ", desk: :billing
216
+ end
217
+ topic :beta, only: ->(requester) { requester.beta? }
218
+ topic :legacy, retired: true # old cases keep their label; nobody can open a new one
219
+ other # the free-form leaf every desk should have
220
+ end
221
+ ```
222
+
223
+ | option | meaning |
224
+ |---|---|
225
+ | `about:` | the `supportable` class(es) this topic is about; the wizard offers the requester's own records |
226
+ | `subject:` | `:required` (must pick one) · `:optional` (offers "none of these") · `:none` (free-form) |
227
+ | `candidates:` | `->(requester) { … }` overriding which records the picker shows |
228
+ | `ask:`, `placeholder:`, `prefill:`, `label:` | copy, when you'd rather not use the locale keys; `prefill:` may be a `->(subject) { … }` |
229
+ | `only:` | `->(requester) { … }` — who is offered this topic in the wizard (agents may still file onto it) |
230
+ | `priority:` | `:normal` · `:high` · `:urgent` — sorts the queue |
231
+ | `desk:` / `route_to:` | send cases on this topic to another desk |
232
+ | `retired:` | hidden from the wizard, still readable on old cases |
233
+ | `icon:` | a key your views may render; the gem never does |
234
+ | `assistant:` | the most an assistant may produce on this branch (see [Assistants](#-assistants)) |
235
+
236
+ `about`, `candidates`, `desk`, `route_to`, `priority`, `only` and `retired`
237
+ are inherited down the branch; copy never is. `assistant:` is the one option
238
+ that is neither inherited nor overridden: `Topic#assistant_cap` is the
239
+ **minimum** over the node and every ancestor, so a child can only tighten
240
+ what a parent allowed. Labels come from
241
+ `support_desk.topics.<path>.label` in your locale files (`ask` and `hint`
242
+ alongside).
243
+
244
+ ```ruby
245
+ ticket.topic # a SupportDesk::Topic value object
246
+ ticket.topic.path # "billing/invoice"
247
+ ticket.topic.label # "Invoice"
248
+ ticket.topic.full_label # "Billing › Invoice"
249
+ ticket.topic.under?(:billing) # true
250
+ ticket.topic.free_form? .subject_required? .retired? .priority .about .icon .assistant_cap
251
+ SupportDesk.find_topic("billing/invoice") # across every desk; nil, never a raise
252
+ ```
253
+
254
+ Agents can refile a case (`ticket.change_topic!(to: "billing/refund", by:)`)
255
+ and point a free-form one at the record it turned out to be about
256
+ (`ticket.attach_subject!(order, by:)`); both are events.
257
+
113
258
  ## The model macros
114
259
 
115
260
  ### `has_support_tickets(desk: :default, as: nil, if: nil)`
@@ -144,6 +289,8 @@ Makes a domain record something people can ask about. Every method has a working
144
289
 
145
290
  Makes someone able to answer. Agents are never chats participants — the desk sends, the agent *authors* — so this needs no messaging setup at all. It adds `support_agent?`, `support_agent_name`, `support_agent_avatar`, `on_duty?`, `support_capacity`, `support_queue` and exactly one verb — `open_support_conversation_with!`, the only agent action with no ticket yet (see [Writing first](#writing-first)). Everywhere else the ticket is the subject of the sentence.
146
291
 
292
+ `kind:` is `:human` or `:ai`, and anything else is a boot failure. Declaring one of your own models `kind: :ai` does **not** hand it an agent's authority: every support write by it, or to it, is refused with `SupportDesk::NotAnAssistant`, and `doctor` warns about it. The only machine that may act on a case is the desk's own `SupportDesk::Assistant` — see [Assistants](#-assistants).
293
+
147
294
  ## Tickets
148
295
 
149
296
  ```ruby
@@ -169,16 +316,51 @@ ticket.attach_subject!(order, by: lucia)
169
316
 
170
317
  Every transition takes `by:` (falling back to `SupportDesk::Current.actor`), runs under the ticket's row lock, writes exactly one event row, and emits its events after the transaction commits. Repeating one that already happened returns `self` and writes nothing.
171
318
 
319
+ More of what a ticket knows:
320
+
321
+ ```ruby
322
+ ticket.opened_by ticket.opened_by_requester? ticket.opened_by_support? # who wrote first
323
+ ticket.opened_via # :in_app | :email | :intercom | :api
324
+ ticket.channels # every channel the case can be answered through
325
+ ticket.channels_summary # "in app · email", in the reader's language
326
+ ticket.requester ticket.assignee ticket.desk ticket.conversation ticket.messages
327
+ ticket.assigned_to?(lucia) ticket.about?(order) ticket.reopened? ticket.unassigned?
328
+ ticket.assignments # the history of who held it; .open for the current seat
329
+ ticket.assignment_history # the same, oldest first
330
+ ticket.events ticket.notes # the append-only timeline, and just the internal notes
331
+ ticket.waiting_since ticket.first_agent_reply_at ticket.last_requester_message_at ticket.last_agent_message_at
332
+ ticket.export # a GDPR-friendly Hash: the requester's transcript and the events they saw, never notes
333
+ ticket.notification_title # "Support · new message" — safe for a lock screen
334
+ ticket.notification_body # the label — for an authenticated feed
335
+ ticket.register!(message) # fold a chats message into the clocks by hand (imports); idempotent, and what the chats subscriber calls
336
+ ticket.desk_config # this desk's slice of the configuration
337
+
338
+ SupportDesk::Ticket.find_by_reference!("t-ab12cd") # forgives case, the prefix and Crockford lookalikes (O→0, I/L→1)
339
+ SupportDesk::Ticket.for_conversation(conversation) # the case behind a chats conversation, or nil
340
+ SupportDesk::Current.actor = lucia # the fallback for every `by:` (the console sets it per request)
341
+ ```
342
+
343
+ An **assignment** row records `agent`, `assigned_by`, `reason` (`taken`
344
+ `assigned` `handed_off` `routed` `drop_in_takeover` `escalated` `reopened`
345
+ `opened`), `note`, `assigned_at`, `released_at` and `release_reason`
346
+ (`handed_off` `released` `shift_end` `closed` `escalated`). An **event** row
347
+ has a `kind` (`opened` `assigned` `handed_off` `released` `drop_in` `closed`
348
+ `reopened` `topic_changed` `subject_attached` `note` `escalated`
349
+ `human_requested` `assistant_paused` `assistant_resumed` `draft_sent`
350
+ `draft_rejected` `assistant_withheld` — plus kinds reserved for later
351
+ releases), an `actor` and a `payload`, and is read-only once written.
352
+
172
353
  ### Scopes
173
354
 
174
355
  ```ruby
175
356
  SupportDesk::Ticket
176
357
  .open .closed .not_closed .assigned .unassigned .assigned_to(lucia)
177
358
  .awaiting_reply .awaiting_requester
359
+ .opened_by_requester .opened_by_support
178
360
  .waiting_over(4.hours) .at_risk .overdue
179
361
  .about(order) .about_any(Order) .on_topic(:billing)
180
- .for_desk(:billing) .opened_via(:email) .opened_between(range)
181
- .most_urgent_first .recent_activity_first .newest_first
362
+ .for_desk(:billing) .opened_via(:email) .opened_between(range) .closed_between(range)
363
+ .most_urgent_first .recent_activity_first .newest_first .oldest_first
182
364
  .find_by_reference("T-AB12CD")
183
365
  ```
184
366
 
@@ -188,11 +370,12 @@ Bring your own UI on the agent side. Everything the console needs is plain Ruby:
188
370
 
189
371
  ```ruby
190
372
  q = lucia.support_queue
191
- q.mine q.unassigned q.awaiting q.open q.closed # relations
192
- q.counts # { awaiting: 4, mine: 2, … } in ONE query
193
- q.badge # the nav number, cached 30s per agent
194
- q.next # the most urgent thing this agent could pick up
195
- q.tabs # [[:awaiting, "Needs a reply", 4], …]
373
+ q.mine q.unassigned q.awaiting q.open q.closed q.needs_human # relations
374
+ q.counts # { awaiting: 4, mine: 2, … } in ONE query
375
+ q.badge # the nav number, cached 30s per agent
376
+ q.next # the most urgent thing this agent could pick up
377
+ q.tabs # [[:awaiting, "Needs a reply", 4], …]
378
+ q.visible_tabs # which ones this desk has any use for
196
379
 
197
380
  ticket.context_card # title, status, the host's own context pairs, the requester
198
381
  ticket.summary # one line for a list row, Slack, or a digest
@@ -200,6 +383,19 @@ ticket.timeline # messages ⨉ events merged by time; .print in a conso
200
383
  ticket.actions_for(lucia) # exactly the buttons to render
201
384
  ```
202
385
 
386
+ ```ruby
387
+ card = ticket.context_card
388
+ card.title card.status card.pairs card.subject_url card.topic_label
389
+ card.requester_name card.requester_avatar card.requester_since card.requester_open_tickets
390
+ card.opened_by_label # "Support · Lucía G." for a case the desk opened; "not recorded" for a 0.1 row awaiting backfill
391
+ card.to_h
392
+
393
+ ticket.summary.to_s # "T-AB12CD · Order SO1 · Alice · awaiting reply (12 minutes)"
394
+ ticket.summary.state # "awaiting reply" | "awaiting requester" | "closed" | "open", translated
395
+ ticket.timeline.entries # Timeline::Entry: .kind (:message or the event kind), .at, .actor, .body
396
+ ticket.timeline.print # in a console
397
+ ```
398
+
203
399
  ## The requester experience
204
400
 
205
401
  Mounting the engine is the whole user side: four screens, ejectable views,
@@ -258,6 +454,22 @@ first would lose to it.
258
454
  Both surfaces are pushed screens, never modals: every wizard step is a real
259
455
  URL, so the back gesture and cold-boot deep links work.
260
456
 
457
+
458
+ ### Every helper the requester views can use
459
+
460
+ | helper | what it renders |
461
+ |---|---|
462
+ | `link_to_support(about:, text:, **html)` | the door into the wizard (nothing when there is nobody to ask, or nothing they may ask about) |
463
+ | `support_unread_badge` | unread support messages, counted against the chats read horizon |
464
+ | `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
465
+ | `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` |
466
+ | `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
467
+ | `support_inbox_door?(viewer)` | whether the inbox should show the door instead of a row (`inbox_entry`) |
468
+ | `support_desk_record(key)`, `support_desk_avatar`, `support_desk_badge` | the desk as a counterpart: record, avatar (initials fallback), verified badge |
469
+ | `support_thread_path(ticket)` | where a case is read: its chats conversation |
470
+ | `open_support_ticket_about(record, requester)` | the open case about a record, or nil — what makes a door lead to the existing conversation |
471
+ | `support_human_door(ticket)` | the way to a person on a case an assistant is or was on, and the status line once one has been asked for (see [Assistants](#-assistants)) |
472
+
261
473
  ### Restyling
262
474
 
263
475
  The views ship with a small bundled stylesheet and semantic classes (the list
@@ -298,8 +510,11 @@ end
298
510
  ```
299
511
 
300
512
  That adds member `reply take assign hand_off release close reopen note
301
- change_topic` and collection `next` and `open_conversation`. It has to sit
302
- inside a `resources` block, since that is what those routes hang off.
513
+ change_topic send_draft reject_draft pause_assistant resume_assistant` and
514
+ collection `next` and `open_conversation`. The last four do nothing until an
515
+ assistant is configured, and the console never offers them before that. It
516
+ has to sit inside a `resources` block, since that is what those routes hang
517
+ off.
303
518
  `new` stays yours: add it to `only:` when you render the form behind
304
519
  `open_conversation` ("Write to someone").
305
520
 
@@ -483,6 +698,56 @@ screen, an order) and says so:
483
698
  config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
484
699
  ```
485
700
 
701
+ #### What the compose form posts, and what comes back
702
+
703
+ The form is yours (`new.html.erb` — the generated one is a fine start); the
704
+ concern owns the request. `new` fills the draft it renders, `open_conversation`
705
+ sends it. Every field is read by name:
706
+
707
+ | param | what it is |
708
+ |---|---|
709
+ | `requester` | a GlobalID from one of your own pages — **authoritative**: a bad one is a refusal, never a fallback to the typed query |
710
+ | `requester_query` | what an agent typed, resolved by `config.find_requester` when no `requester` was supplied |
711
+ | `about` | a GlobalID of a `supportable` record; the topic comes with it |
712
+ | `topic` | a free-form topic path when there is no subject |
713
+ | `body`, `files[]` | the message: text, uploads, or Active Storage signed blob ids — text **or** an attachment is enough |
714
+ | `desk` | the desk key the agent is working; an explicit key that isn't a visible desk is refused, never silently swapped for the default |
715
+
716
+ GlobalIDs resolve only inside the classes that declared themselves
717
+ (`has_support_tickets`, `supportable`), so a token is an identifier and never
718
+ permission to call `find` on whatever it names. The concern sets `@requester`,
719
+ `@about`, `@topic`, `@body`, `@files` and `@requester_query` **before** it
720
+ looks anything up, so every refusal re-renders your `new` as a **422 with the
721
+ draft still in it** (for Turbo too — a stream refresh would throw it away);
722
+ success is a **303** to the case, with the flash `message_sent` whether the
723
+ case was opened or the message joined one the person already had open. The
724
+ refusals are distinct and each has its own copy under
725
+ `support_desk.console.errors`: `unknown_requester`, `not_a_requester` (a
726
+ closed account), `invalid_requester`, `invalid_subject`, `blank_message`,
727
+ `invalid_input`, `no_requester_lookup`, `writing_to_yourself`, `off_duty`.
728
+
729
+ Two named seams, for hosts that look people up their own way (a multi-tenant
730
+ host scopes **both**, since `find_requester` only guards the typed path):
731
+
732
+ ```ruby
733
+ def support_conversation_requester # the person, or nil; raise SupportDesk::Console::InvalidInput, :invalid_requester to refuse
734
+ def support_conversation_subject # what it's about, or nil; the model re-checks supportable_by? at the write
735
+ ```
736
+
737
+ Helpers your `new` template can read: `support_conversation_topics` (the
738
+ free-form leaves), `support_conversation_offered?` (show the door at all: on
739
+ duty, and `config.authorize_console` says yes for `:open_conversation` with a
740
+ nil ticket), `support_conversation_sendable?` (render the send button), and
741
+ `support_desk_record` (the desk being written as). When the message turns out
742
+ to be a reply into an open case, `config.authorize_console(agent, ticket,
743
+ :reply)` and `ticket.actions_for(agent)` are asked again, **under that case's
744
+ row lock**, before anything is written.
745
+
746
+ The verbs live in one table the router reads:
747
+ `SupportDesk::Console::MEMBER_VERBS` (reply take assign hand_off release close
748
+ reopen note change_topic) and `SupportDesk::Console::COLLECTION_VERBS`
749
+ (`next: :get, open_conversation: :post`).
750
+
486
751
  ### Who can be written to
487
752
 
488
753
  `has_support_tickets if: :kept?` is the whole policy. When it turns false
@@ -491,6 +756,727 @@ the model, a direct chats write — and nothing is hidden or deleted: the
491
756
  transcript stays readable, the case stays in the queue, and agents can
492
757
  still take notes and close it.
493
758
 
759
+ ### Upgrading to 0.2
760
+
761
+ ```bash
762
+ rails generate support_desk:upgrade # copies the additive opened_by migration (the same file a fresh install runs)
763
+ rails db:migrate # backfills every existing case to its requester
764
+ rake support_desk:backfill_opened_by # the idempotent catch-up, once the old processes are gone
765
+ ```
766
+
767
+ **This is a drained cutover, not a rolling deploy.** 0.1 processes revalidate
768
+ `Assignment#reason` on `release!`/`close!`/`hand_off!` and reject the new
769
+ `opened` reason, so a case the desk opened must not exist while 0.1 code can
770
+ still touch it: migrate, pause support writes, stop and drain all old web
771
+ requests **and workers**, run the catch-up, verify
772
+ `SupportDesk::Ticket.where(opened_by_id: nil).count == 0` and
773
+ `SupportDesk.doctor` (its `provenance` check says exactly that), then start
774
+ only 0.2. A NULL `opened_by` is read as requester-opened in the meantime, so
775
+ quotas, labels and metrics stay right before the catch-up. Rolling back is a
776
+ code rollback, never a schema one; with staff-opened cases live, prefer
777
+ dropping `new`/`open_conversation` from your routes over downgrading. If you
778
+ would rather avoid the window, ship a `0.1.4` that only adds `opened` to
779
+ `Assignment::REASONS` first.
780
+
781
+ Requesters see one more state in their list — `support_desk.tickets.state
782
+ .opened_by_support` ("We wrote to you") — until they answer, and every error
783
+ the gem raises still inherits `SupportDesk::Error`; the new one is
784
+ `SupportDesk::NotARequester` (no `has_support_tickets`, or its `if:` said no).
785
+ `SupportDesk.humanize_duration` is where "1 day" / "4 horas" now comes from
786
+ (`Wizard.humanize_duration` still delegates to it).
787
+
788
+ ## 🤖 Assistants
789
+
790
+ An assistant is an agent that happens to be a machine. She has a seat, a
791
+ name, a turn budget and a **level** that says what she may produce on this
792
+ case — and at the default level that is a proposal a person reads and sends,
793
+ signed by them.
794
+
795
+ The gem does not call a model. It emits one event and accepts a handful of
796
+ verbs; which provider you use, what you put in a prompt and what you spend
797
+ are yours. What lives here is the part nobody should write twice: who may
798
+ say what to whom, what happens when the customer writes again mid-answer,
799
+ and how a person takes over.
800
+
801
+ **Nothing in this section is on until you turn it on.** Without
802
+ `config.assistant`, `support_desk` behaves exactly as it did in 0.2.
803
+
804
+ ### Two minutes
805
+
806
+ ```bash
807
+ rails g support_desk:assistant Rose --disclosure signature
808
+ ```
809
+
810
+ It writes `app/jobs/support/rose_turn_job.rb` and
811
+ `app/services/support/rose.rb`, and it **prints** the rest — it never edits
812
+ your initializer, because `config.assistant` is a policy decision and a
813
+ generator that wrote one would turn "let me look at this" into a live
814
+ assistant. Paste the stanza:
815
+
816
+ ```ruby
817
+ # config/initializers/support_desk.rb
818
+ config.assistant :rose do |rose|
819
+ rose.name = "Rose"
820
+ rose.autonomy = :draft # she proposes; a person sends
821
+ rose.disclosure = :signature # REQUIRED — see below
822
+ rose.max_turns = 6
823
+ rose.responds_within = 3.minutes
824
+ end
825
+ config.default_assistant = :rose
826
+ ```
827
+
828
+ Wire the job to the one event the gem emits:
829
+
830
+ ```ruby
831
+ SupportDesk.on(:assistant_turn, key: "support.rose.turn") do |ticket, assistant, _message, turn:|
832
+ Support::RoseTurnJob.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
833
+ end
834
+ ```
835
+
836
+ And the job, which is the whole harness contract in nine lines:
837
+
838
+ ```ruby
839
+ def perform(ticket_id, assistant_key, turn)
840
+ ticket = SupportDesk::Ticket.find(ticket_id)
841
+ return unless ticket.assistant_turn == turn # the case moved on while we waited
842
+
843
+ rose = SupportDesk.assistant(assistant_key)
844
+ return unless ticket.assistant_policy(rose).may_observe?
845
+
846
+ answer = Support::Rose.answer(ticket.brief, ticket.transcript)
847
+ ticket.respond!(answer.text, by: rose, turn: turn, confidence: answer.confidence)
848
+ end
849
+ ```
850
+
851
+ What a person sees next: a card at the top of the case that says "Propuesta
852
+ de Rose", the text, a confidence pill, the sources she cited, and three
853
+ buttons — **Enviar**, **Editar**, **Descartar**. The sent message is
854
+ *theirs*, signed with their name. The customer sees an answer from a human,
855
+ because it is one.
856
+
857
+ ### Levels
858
+
859
+ `autonomy` is the ceiling she may ever work at. Every level is the one below
860
+ it plus its own verbs:
861
+
862
+ | level | she may | what it feels like |
863
+ |---|---|---|
864
+ | `:off` | nothing | configured, switched off |
865
+ | `:observe` | `note`, `escalate`, `release` | she reads and can leave a staff note or hand the case to a person; she never writes to the customer |
866
+ | `:draft` | the above + `draft` | **the default.** Every word goes through a person |
867
+ | `:reply` | the above + `reply`, `take` | she answers the customer and holds the case |
868
+ | `:resolve` | the above + `close` | she can close a case she holds once the customer has been answered |
869
+
870
+ ```ruby
871
+ ticket.assistant_policy.level # => :draft
872
+ ticket.assistant_policy.because # => "topic payments caps rose at draft"
873
+ ticket.assistant_policy.may_reply? # => false
874
+ ticket.assistant_policy.allowed_verbs # => [:note, :escalate, :release, :draft]
875
+ ```
876
+
877
+ ### Ceilings and floors
878
+
879
+ A **ceiling** lowers what she may ever produce here. The lowest one wins,
880
+ and `because` names the single rule that decided it — so any refusal traces
881
+ back to one line of configuration:
882
+
883
+ | ceiling | set by |
884
+ |---|---|
885
+ | her autonomy | `rose.autonomy = :reply` |
886
+ | the topic | `topic :payments, assistant: :draft` — the minimum over the node and every ancestor, so a child can only tighten |
887
+ | your block | `rose.cap { |ticket| :draft if ticket.requester.try(:vip?) }` |
888
+ | the case | what a reopen writes (below) |
889
+ | a pause | a human switched her off on this case |
890
+
891
+ A **floor** lowers the level because of the case's state, after the ceilings:
892
+ she is deactivated, the case is closed, a person has been asked for, or a
893
+ human holds it.
894
+
895
+ ```ruby
896
+ ticket.assistant_policy.ceilings # => { assistant: :reply } only the ones that apply
897
+ ticket.assistant_policy.floors # => [:held_by_human] Lucía took it: :reply becomes :draft
898
+ ticket.assistant_policy.to_h # all five ceiling slots, spelled out, for a log line
899
+ ```
900
+
901
+ Policy decides what she may **produce**. It never decides what a person can
902
+ see: the transcript, the case, the queue and the door to a human are the
903
+ same at every level, and a refusal is always a named reason on a record — an
904
+ `assistant_withheld` event, a policy stored in a proposal's metadata — never
905
+ silence.
906
+
907
+ ### `respond!` and its three outcomes
908
+
909
+ `respond!` is the verb a harness should call. It hands over an answer and
910
+ lets policy decide what that answer becomes, so the harness never encodes
911
+ rules that change per case:
912
+
913
+ ```ruby
914
+ outcome = ticket.respond!(text, by: rose, turn: turn, confidence: 0.82, sources: [{ title: "…", url: "https://…" }])
915
+
916
+ outcome.sent? # it went to the requester (level :reply or above, and it was her turn)
917
+ outcome.drafted? # a person will send it; outcome.draft is the row
918
+ outcome.withheld? # nothing was written; outcome.reason is :policy or :not_your_turn
919
+ outcome.escalated? # the budget ran out: the proposal is waiting AND so is a person
920
+ outcome.turn # the successor turn, for a second action in the same run
921
+ ```
922
+
923
+ `draft!` proposes regardless of level, for a host that has already decided it
924
+ wants a proposal. `reply!(by: rose, turn:)` and `note!(by: rose, turn:)` are
925
+ the ordinary verbs with a turn attached.
926
+
927
+ ### The turn
928
+
929
+ `ticket.assistant_turn` is an opaque string (`"t7-r12"`) over an integer
930
+ bumped by **every** registered message and **every** transition. Every
931
+ assistant action requires it and consumes it:
932
+
933
+ ```ruby
934
+ turn = ticket.assistant_turn # read it before you call a model
935
+ ticket.respond!(answer, by: rose, turn: turn)
936
+ ```
937
+
938
+ A model takes seconds and a customer can write again while it thinks. The
939
+ turn is what makes that safe: the action is compared against the case's
940
+ current revision **under its row lock**, and a late, retried or redelivered
941
+ one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
942
+ also why this gem has no idempotency keys, no claim rows and no leases —
943
+ "is this still the case you read?" is already answered.
944
+
945
+ Every verb of hers takes it, and that includes taking and giving back the
946
+ seat: `assign!(to: rose, by: rose, turn:)` and `release!(by: rose, turn:)`
947
+ require it when `by:` is an assistant (an omitted turn is an
948
+ `ArgumentError`, a stale one a `StaleTurn`), because a run that finished
949
+ after the case moved on must not release the seat a newer one took. A
950
+ **person's** calls are unchanged and take no turn.
951
+
952
+ Two consequences worth knowing:
953
+
954
+ - Check `ticket.assistant_turn == turn` in your job **before you spend
955
+ money**. A mismatch means the newer turn's job already exists.
956
+ - A retry after a committed action is a `StaleTurn`, and that is correct:
957
+ the work was done. Generated jobs `discard_on` it.
958
+
959
+ #### Ticket, then conversation
960
+
961
+ A turn is only as good as the reconciliation behind it, and reconciliation
962
+ is a `SELECT`: on its own it cannot exclude a message that commits a
963
+ millisecond later. Your customer never takes the case's row lock — they
964
+ press send, and chats writes a message.
965
+
966
+ What chats *does* take is the **conversation row**: every message insert
967
+ updates `chats_conversations` inside its own transaction. So every path
968
+ that speaks takes the ticket's row lock and then that row, before
969
+ reconciling. A question already in flight holds it, so we wait for it and
970
+ the turn goes stale; a question that starts after we hold it waits for us
971
+ and raises a turn of its own. **The order is always ticket → conversation**,
972
+ in this gem and in anything you add to it.
973
+
974
+ What that does not cover: a message whose `INSERT` was already stamped when
975
+ we won the row is still stamped earlier than the answer, so a transcript can
976
+ show a question above an answer that did not address it. That is a
977
+ genuinely simultaneous send, and its own turn follows.
978
+
979
+ > [!IMPORTANT]
980
+ > **This is a row lock, so it is PostgreSQL and MySQL.** SQLite has none:
981
+ > it serializes writes, and in WAL mode a reconciliation `SELECT` reads the
982
+ > last committed snapshot straight through a requester's open write
983
+ > transaction, so a question committing behind an answer is still missed
984
+ > there. Run PostgreSQL or MySQL for a production desk with an assistant —
985
+ > `SupportDesk.doctor` warns when you haven't.
986
+
987
+ ### Proposals in the console
988
+
989
+ A pending proposal renders above the composer with its confidence, its
990
+ sources and its attachments, and goes stale visibly the moment anything on
991
+ the case moves.
992
+
993
+ ```ruby
994
+ draft = ticket.pending_draft
995
+ draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # verbatim
996
+ draft.send!(by: lucia, seen_turn: ticket.assistant_turn, body: "Casi: …") # edited
997
+ draft.reject!(by: lucia, reason: "no es eso")
998
+ ```
999
+
1000
+ `seen_turn` is the turn the reviewer's **page** was rendered with, and a
1001
+ mismatch is a refusal, not a warning: approving a proposal from a page that
1002
+ predates the customer's next message would send an answer into a
1003
+ conversation that has moved on. There is no "send anyway" flag — the console
1004
+ re-renders the case with the current turn and the reviewer's text still in
1005
+ the box, and they submit again. One pending proposal per case, always: a
1006
+ newer one, a takeover, a human reply or a pause supersedes the last.
1007
+
1008
+ A sent proposal is the **human's** message. `sent_body` keeps the edit, the
1009
+ original body stays on the row, and `draft.edited?` / the `verbatim` and
1010
+ `edited` scopes are the acceptance numbers you raise her level on.
1011
+
1012
+ ### The two exits
1013
+
1014
+ Either side can ask for a person, and both do the same write: her seat is
1015
+ released, the reason is recorded, the priority goes up, and a line lands in
1016
+ the thread.
1017
+
1018
+ ```ruby
1019
+ ticket.escalate!(by: rose, turn: turn, reason: "refund_over_limit", summary: "Pidió el reembolso de …")
1020
+ ticket.request_human!(by: alice) # the requester's own door
1021
+ ```
1022
+
1023
+ Render the door in your thread — it is a partial in the requester engine,
1024
+ and a helper:
1025
+
1026
+ ```erb
1027
+ <%= render "support_desk/tickets/human_door", ticket: ticket %>
1028
+ <%= support_human_door(ticket) %>
1029
+ ```
1030
+
1031
+ It shows the button while an assistant is or has been in play on the case,
1032
+ and the status line once a person has been asked for. **History counts**:
1033
+ the door does not vanish because somebody edited an initializer after she
1034
+ answered. Both exits reach the queue's `needs_human` tab, which appears only
1035
+ on desks that have an assistant or a non-zero count.
1036
+
1037
+ ### Disclosure
1038
+
1039
+ Required, with no default. Boot fails until you choose:
1040
+
1041
+ | mode | what the requester gets |
1042
+ |---|---|
1043
+ | `:signature_and_notice` | her messages are signed "Rose · asistente virtual" **and** the thread opens with a notice |
1044
+ | `:signature` | signed; no notice |
1045
+ | `:notice` | a notice; her messages are unsigned, from the desk |
1046
+ | `:none` | nothing is said and nothing is signed |
1047
+
1048
+ **Your legal process decides this, not us.** The gem refuses to pick a
1049
+ default because the right answer depends on a jurisdiction, a sector and a
1050
+ risk appetite it knows nothing about. What it does guarantee is that the
1051
+ choice is never invisible to *you*: whatever the mode, every machine-written
1052
+ message carries `metadata["support_desk"]` naming the assistant, the mode,
1053
+ the turn and the whole policy that allowed it, `ticket.export` labels it
1054
+ `from: "assistant"` in **every** mode including `:none`, and the console
1055
+ marks it for staff. What a customer is told is a product decision; what your
1056
+ records say a machine wrote is not.
1057
+
1058
+ **Historical disclosure is captured per message.** The default Chats signature
1059
+ renderer uses the assistant name and disclosure stored when the message was
1060
+ sent. Renaming, changing mode or removing configuration does not rewrite old
1061
+ signed bubbles. Human replies and human-approved drafts retain their normal
1062
+ human signatures. The assistant row snapshot is a fallback for legacy messages
1063
+ without provenance; their original disclosure cannot be reconstructed if it
1064
+ was never stored.
1065
+
1066
+ A host's `Chats.config.message_signature` override remains authoritative. If
1067
+ you customize it, preserve the human fallback as well as the assistant snapshot:
1068
+
1069
+ ```ruby
1070
+ Chats.configure do |config|
1071
+ config.message_signature = lambda do |message|
1072
+ stamp = message.metadata["support_desk"]
1073
+ name = if stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true &&
1074
+ stamp["display_name"].is_a?(String) && stamp["display_name"].present?
1075
+ stamp["display_name"]
1076
+ else
1077
+ Chats.display_name_for(message.author)
1078
+ end
1079
+ I18n.t("chats.message.signature", name: name)
1080
+ end
1081
+ end
1082
+ ```
1083
+
1084
+ ### Humans outrank
1085
+
1086
+ - A person may answer a case she holds under **every** `reply_policy`.
1087
+ `:assignee_only` exists so two people don't answer at once, and she is not
1088
+ one.
1089
+ - A human reply on her case takes it over and supersedes the pending
1090
+ proposal.
1091
+ - `ticket.agents_to_notify` and `desk.humans` return people only. Notifying a
1092
+ machine is notifying nobody. (`desk.agents` includes her, and is an Array.)
1093
+ - She can never send her own proposal, hand a case off, change a topic or
1094
+ attach a subject. She escalates instead.
1095
+
1096
+ ### Pause, resume, and what a reopen remembers
1097
+
1098
+ ```ruby
1099
+ ticket.pause_assistant!(by: lucia, reason: "cliente enfadado") # floors her at :off on THIS case
1100
+ ticket.resume_assistant!(by: lucia)
1101
+ ```
1102
+
1103
+ Pausing releases her seat and throws away her pending proposal. Resuming
1104
+ clears the pause and **nothing else** — a case cap and a request for a
1105
+ person are different decisions made by different people, and only an
1106
+ explicit hand-back (`assign!(to: rose, by: lucia)`) lifts those.
1107
+
1108
+ Reopening a case she closed leaves it unassigned and writes
1109
+ `assistant_cap = "draft"` for the rest of its life. A case that came back is
1110
+ a case where her answer was not the end of it.
1111
+
1112
+ ### When the harness is down
1113
+
1114
+ Two scheduled tasks, and they are the difference between a delay and a
1115
+ customer nobody answers:
1116
+
1117
+ ```yaml
1118
+ # config/recurring.yml
1119
+ support_desk_release_silent_assistants:
1120
+ command: "SupportDesk.release_silent_assistants!"
1121
+ schedule: every minute
1122
+ support_desk_redispatch_assistant_turns:
1123
+ command: "SupportDesk.redispatch_assistant_turns!"
1124
+ schedule: every 5 minutes
1125
+ ```
1126
+
1127
+ `release_silent_assistants` hands over every case she has sat on longer than
1128
+ her `responds_within` without answering — a worker that stopped, a provider
1129
+ that is down, a job that spent its last retry. It asks for a **person** on
1130
+ each one, not just her seat back: a case that waited that long deserves one
1131
+ whatever she would have said. `redispatch_assistant_turns` re-emits the turn
1132
+ for cases nobody acted on, which is safe precisely because the turn is
1133
+ consumed by the first action and every later one is a `StaleTurn`.
1134
+
1135
+ `release_silent_assistants` runs `SupportDesk.reclaim_assistant_seats!`
1136
+ first, and you can run that on its own
1137
+ (`rake support_desk:reclaim_assistant_seats`). It is a different question
1138
+ from silence: it reads the **seats that exist** rather than the assistants
1139
+ this process happens to have configured, and gives back every one whose
1140
+ holder is switched off, no longer declared, or no longer allowed to hold a
1141
+ case — with no `responds_within` and no overdue clock anywhere in it. That
1142
+ is what makes `deactivate!` and a flag flipped off actually release her
1143
+ cases, and it is why the doctor's seat checks keep running when the
1144
+ configuration is gone.
1145
+
1146
+ `redispatch_assistant_turns` also repairs before it decides: a requester
1147
+ message whose registration was lost after its commit leaves the clocks
1148
+ describing a case that no longer exists, so the task looks for unregistered
1149
+ **messages** and not only for idle clocks. The repair commits on its own,
1150
+ which is what makes a dead process followed by nothing but this task end in
1151
+ an actionable turn.
1152
+
1153
+ `rake support_desk:assistant_status` reports current counts without changing
1154
+ tickets. Resolving configured assistants may create their identity rows or
1155
+ refresh their name/disclosure snapshots. `SupportDesk.doctor` covers the same ground
1156
+ with verdicts.
1157
+
1158
+ ### What the model sees
1159
+
1160
+ > [!WARNING]
1161
+ > **`ticket.brief` and `ticket.transcript` are what your harness sends to a
1162
+ > third party.** Two fields in the brief are host data you chose:
1163
+ > `Supportable#support_context` (about the thing the case is about) and
1164
+ > `Requester#support_context` (about the person). `include_internal: true`
1165
+ > adds the desk's private reasoning — notes agents left each other, and
1166
+ > proposals a human rejected with the reason. Decide what belongs in a
1167
+ > prompt before you fill those in, not after.
1168
+
1169
+ ```ruby
1170
+ brief = ticket.brief(include_internal: false, transcript_limit: 50)
1171
+ brief.to_h # versioned data — schema_version, desk, assistant, case, requester, transcript
1172
+ brief.to_text # the same facts as sectioned plain text
1173
+ brief.policy # what she may do here, and why
1174
+
1175
+ ticket.transcript.to_text
1176
+ # [2026-09-18 10:02] Alice: No me han pagado
1177
+ # [2026-09-18 10:03] Rose: Lo estoy mirando ahora mismo
1178
+ # [2026-09-18 10:07] Lucía: Ya está resuelto [justificante.pdf]
1179
+ ```
1180
+
1181
+ A brief is **facts, never instructions**. There is not one imperative
1182
+ sentence in it and there never will be: what the assistant should *do* with
1183
+ a case is your prompt and your product. The one thing it states about
1184
+ behaviour is `may` / `may_not`, and that is not advice either — it is the
1185
+ authorization, straight off the policy, so a harness never has to re-derive
1186
+ the rules it is working under.
1187
+
1188
+ The transcript speaks four roles (`:requester`, `:human`, `:assistant`,
1189
+ `:system`) where `Ticket#role_of` speaks three: whose turn it is does not
1190
+ change because a machine wrote the desk's last word, but a reader cares.
1191
+ Deleted messages stay in it as tombstones, and an assistant's name follows
1192
+ your configuration — rename her and the whole transcript renames, drop her
1193
+ from the initializer and it keeps saying what the customer was actually
1194
+ shown.
1195
+
1196
+ ### Before you launch
1197
+
1198
+ - [ ] `SupportDesk.doctor.print` is green. It checks her bindings, that
1199
+ `Chats.display_name_for` answers for her, that no case she holds needs
1200
+ a person, that a turn subscriber exists, and that nothing has been
1201
+ idle longer than three times her `responds_within`.
1202
+ - [ ] **Moderation covers both shapes.** A signed message has her as its
1203
+ author; a `:notice` or `:none` message has no author at all and is the
1204
+ desk's. Whatever owns your moderation has to catch the nameless one
1205
+ too.
1206
+ - [ ] Notifiers subscribe to `draft_proposed` (a proposal is waiting),
1207
+ `ticket_escalated` and `human_requested` (somebody needs a person).
1208
+ - [ ] The `_human_door` partial renders in your thread.
1209
+ - [ ] Both rake tasks are scheduled, and you have watched them run once.
1210
+ - [ ] `autonomy` is `:draft`, and topic caps are on money and identity.
1211
+ Raise her only when the acceptance rate on reviewed proposals says so.
1212
+
1213
+ ### Reference
1214
+
1215
+ **Configuration**
1216
+
1217
+ | setting | type | default |
1218
+ |---|---|---|
1219
+ | `name` | String | the key, humanized |
1220
+ | `avatar` | String, or a callable given the assistant | nil |
1221
+ | `autonomy` | one of `AssistantPolicy::LEVELS` | `:draft` |
1222
+ | `disclosure` | **required**: `:signature_and_notice` · `:signature` · `:notice` · `:none` | — |
1223
+ | `max_turns` | positive Integer, or nil for unlimited (doctor warns) | 6 |
1224
+ | `responds_within` | a Duration, or nil to disable the silent sweep (doctor warns) | 3 minutes |
1225
+ | `may_open_conversations` | true / false | false |
1226
+ | `hand_off_line` · `human_requested_line` · `disclosure_line` | String (`%{name} %{desk} %{reply_within}`), I18n key, block, or nil | the gem's copy |
1227
+ | `hand_off_when` | block `(ticket, message)` → true / false / nil; anything else, or a raise, hands the case over | nil |
1228
+ | `cap` | block `(ticket)` → a level or nil | nil |
1229
+
1230
+ Predicates: `signs?`, `notice?`, `disclosed?`, `may_open_conversations?`,
1231
+ `line_for(setting, ticket)`. The four modes are
1232
+ `AssistantConfiguration::DISCLOSURE_MODES`.
1233
+
1234
+ ```ruby
1235
+ config.assistant :rose { |rose| … } config.assistant(:rose) config.assistants config.assistant?(:rose)
1236
+ config.default_assistant = :rose
1237
+ config.desk(:billing) { |desk| desk.assistant = :rose } # an explicit nil DISABLES that desk
1238
+ config.desk(:billing).assistant_key
1239
+ topic :payments, assistant: :draft # Topic#assistant_cap — tightens only
1240
+ acts_as_support_agent kind: :ai # validated; see the upgrade note
1241
+ ```
1242
+
1243
+ **Module**
1244
+
1245
+ ```ruby
1246
+ SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1247
+ SupportDesk.reset_assistants!
1248
+ SupportDesk.ai_actor?(record)
1249
+ SupportDesk.release_silent_assistants! # → Integer (runs reclaim_assistant_seats! first)
1250
+ SupportDesk.reclaim_assistant_seats! # → Integer; seats she may no longer sit in
1251
+ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
1252
+ ```
1253
+
1254
+ **`SupportDesk::Assistant`**
1255
+
1256
+ | | |
1257
+ |---|---|
1258
+ | `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
1259
+ | `config` / `configured?` | her slice of the configuration; whether anything still declares her |
1260
+ | `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration; `name` and `disclosure` fall back to the snapshot on her row, the rest to the safe answer |
1261
+ | `disclosed?` `signs?` `notice?` | the mode, as predicates |
1262
+ | `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
1263
+ | `on_duty?` `support_capacity` | the agent contract |
1264
+ | `deactivate!(by:, reason:)` / `activate!(by:)` | the cross-process kill switch; rows are never destroyed |
1265
+ | `desks` / `held_tickets` | where she works, and what she is sitting on |
1266
+
1267
+ **`SupportDesk::AssistantPolicy`**
1268
+
1269
+ `LEVELS` · `RANK` · `VERBS_BY_LEVEL` · `ALL_VERBS` ·
1270
+ `AssistantPolicy.for(ticket, assistant, hand_back: false)` · `level` ·
1271
+ `because` · `ceilings` · `floors` · `assistant` · `ticket` ·
1272
+ `at_least?(level)` · `may?(verb)` · `may_observe?` · `may_draft?` ·
1273
+ `may_reply?` · `may_hold?` · `may_close?` · `allowed_verbs` ·
1274
+ `forbidden_verbs` · `to_h` · `to_s` · `null?` (and `AssistantPolicy::Null`,
1275
+ whose `because` says which of "no assistant here" and "not this desk's
1276
+ assistant" it was).
1277
+
1278
+ **`SupportDesk::Ticket`**
1279
+
1280
+ ```ruby
1281
+ # verbs
1282
+ respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
1283
+ draft!(body, by:, turn:, …) # → Draft
1284
+ escalate!(by:, reason:, summary:, turn:, request:)
1285
+ assign!(to:, by:, reason:, note:, request:, turn:) # turn: required when by: is an assistant
1286
+ release!(by:, reason: :released, request:, turn:) # idem
1287
+ request_human!(by:, request:) # by: must be the requester
1288
+ pause_assistant!(by:, reason:) / resume_assistant!(by:)
1289
+
1290
+ # readers
1291
+ assistant assistant_policy(assistant = self.assistant, hand_back: false) assistant_turn
1292
+ assistant_in_play? held_by_assistant? human_required? assistant_paused? assistant_turns_left
1293
+ assistant_message?(message) transcript(limit: nil) brief(include_internal:, transcript_limit:)
1294
+ drafts pending_draft notes
1295
+
1296
+ # scopes
1297
+ held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
1298
+ resolved_by_assistant with_pending_draft assistant_idle_since(time)
1299
+ with_unregistered_requester_messages
1300
+ ```
1301
+
1302
+ Columns: `assistant_revision`, `last_requester_message_id`,
1303
+ `assistant_turns_count`, `assistant_acted_at`, `assistant_paused_at`,
1304
+ `assistant_paused_reason`, `assistant_cap`, `human_required_at`,
1305
+ `human_required_reason`.
1306
+
1307
+ **`SupportDesk::Draft`**
1308
+
1309
+ `STATUSES` · `MAX_SOURCES` (20) · `MAX_SOURCE_TITLE` (500) ·
1310
+ `MAX_SOURCE_URL` (2048) · `send!(by:, seen_turn:, body:, request:)` ·
1311
+ `reject!(by:, reason:, request:)` · `pending?` `sent?` `rejected?`
1312
+ `superseded?` `expired?` · `stale?` · `edited?` · `final_body` ·
1313
+ `confidence_percent` · `author_key` · scopes `pending` `sent` `rejected`
1314
+ `superseded` `expired` `reviewed` `verbatim` `edited` `by(author)`
1315
+ `chronological` `newest_first`.
1316
+
1317
+ **`SupportDesk::Outcome`** — `action` `message` `draft` `policy` `reason`
1318
+ `turn` · `sent?` `drafted?` `withheld?` `escalated?` · `to_h`.
1319
+
1320
+ **`SupportDesk::Transcript`** — `Turn(role:, name:, body:, at:,
1321
+ attachments:, assisted:, message:)` with `requester?` `human?` `assistant?`
1322
+ `system?` `assisted?` and `to_line`; `to_a` `to_h` `to_text` `last(n)`
1323
+ `since(message)` `size`, Enumerable, and `limit:`. `to_h` carries
1324
+ `truncated`, because a reader seeing the last 50 of 300 turns has to know.
1325
+
1326
+ **`SupportDesk::Brief`** — `SCHEMA_VERSION` · `to_h` · `to_text` · `policy`
1327
+ · `include_internal?` · `transcript`.
1328
+
1329
+ **`Requester#support_context`** — overridable, `{}` by default, the same
1330
+ meaning as `Supportable#support_context`. `ContextCard#requester_pairs`
1331
+ renders it next to the subject's own `pairs`, and `to_h` carries it under
1332
+ `requester.context`.
1333
+
1334
+ **`SupportDesk::Desk`** — `assistant` · `assistant?` · `humans` (people
1335
+ only) · `agents` (people plus her, an Array).
1336
+
1337
+ **Events**
1338
+
1339
+ | event | arguments |
1340
+ |---|---|
1341
+ | `assistant_turn` | `ticket, assistant, message, turn:` — the only one a harness subscribes to |
1342
+ | `draft_proposed` | `ticket, draft` |
1343
+ | `draft_sent` | `ticket, draft, message, by:` |
1344
+ | `draft_rejected` | `ticket, draft, by:, reason:` |
1345
+ | `assistant_withheld` | `ticket, assistant, reason:, policy:` |
1346
+ | `ticket_escalated` | `ticket, from:, reason:, by:` |
1347
+ | `human_requested` | `ticket, by:, reason:` |
1348
+ | `assistant_paused` / `assistant_resumed` | `ticket, by:` |
1349
+
1350
+ New `Event::KINDS`: `human_requested` `assistant_paused` `assistant_resumed`
1351
+ `draft_sent` `draft_rejected` `assistant_withheld` — the last five join
1352
+ `note` and `drop_in` in `Event::INTERNAL_KINDS`, which is what
1353
+ `Event.requester_visible` excludes. An export says a person was asked for;
1354
+ it never says a machine's proposal was discarded. `Event#summary` reads the
1355
+ paragraph an escalation left.
1356
+
1357
+ **Errors** — `SupportDesk::NotAnAssistant` (an AI-kind actor that is not
1358
+ this desk's assistant; a subclass of `NotAnAgent`),
1359
+ `SupportDesk::AssistantNotAllowed` (carries `policy` and `verb`; a subclass
1360
+ of `NotAllowed`), `SupportDesk::StaleTurn` (a subclass of
1361
+ `InvalidTransition`, so `rescue InvalidTransition` still catches it).
1362
+
1363
+ **Console** — `MEMBER_VERBS` gains `send_draft` `reject_draft`
1364
+ `pause_assistant` `resume_assistant`; helpers `support_pending_draft` and
1365
+ `support_assistant`; `unavailable_reason` gains `"no_pending_draft"`. Picker
1366
+ values are `SupportDesk.actor_key(agent)`. `Queue::TABS` gains
1367
+ `:needs_human`, hidden by `visible_tabs` unless the desk has an assistant or
1368
+ the count is non-zero.
1369
+
1370
+ **Requester engine** — `POST /tickets/:id/request_human`, and
1371
+ `support_desk/tickets/_human_door` (local: `ticket`) behind the
1372
+ `support_human_door(ticket)` helper.
1373
+
1374
+ **Rake** — `support_desk:release_silent_assistants` ·
1375
+ `support_desk:reclaim_assistant_seats` ·
1376
+ `support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
1377
+ `support_desk:assistant_status`.
1378
+
1379
+ **Doctor** — `assistants (config)` · `assistant turn subscriber` ·
1380
+ `assistant authorship` · `assistant silence` · `assistant seats` ·
1381
+ `assistant idle turns` · `drafts` · `ai agents without policy` · `message registrations`.
1382
+
1383
+ **Test helpers**
1384
+
1385
+ | helper | |
1386
+ |---|---|
1387
+ | `support_assistant(key = nil)` | the record |
1388
+ | `respond_as(assistant, ticket, body, turn:, **options)` | → `Outcome` |
1389
+ | `draft_as(assistant, ticket, body, turn:, **options)` | → `Draft` |
1390
+ | `assert_pending_draft(ticket, body:)` / `refute_pending_draft` | `body:` takes a String (substring) or a Regexp |
1391
+ | `assert_needs_human(ticket, reason:)` / `refute_needs_human` | |
1392
+ | `assert_held_by_assistant(ticket, assistant = nil)` | |
1393
+ | `refute_assistant_spoke(ticket)` | no machine has said anything to the requester |
1394
+ | `assert_assistant_policy(ticket, level, because:)` | the level **and** the sentence |
1395
+ | `with_assistant_config(key = nil, **overrides) { … }` | |
1396
+ | `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
1397
+
1398
+ ### Upgrading to 0.3.2 (including hosts without assistants)
1399
+
1400
+ 0.3.2 adds `support_desk_message_registrations`, an internal receipt per text
1401
+ message. A receipt, written with the ticket clocks and revision in one
1402
+ transaction, distinguishes a replay from an unseen message. Timestamp and UUID
1403
+ order are not evidence of delivery. Every unseen requester message invalidates
1404
+ the turn, notifies the host and runs handoff detection once, even if its timestamp
1405
+ is older; the SLA timestamps themselves never move backwards.
1406
+
1407
+ **This upgrade requires a drained cutover; it is not rolling-safe.** The table
1408
+ is additive, but old processes cannot write receipts.
1409
+
1410
+ 1. Pause support writes and drain old web requests and jobs. Keep the assistant
1411
+ disabled throughout the cutover.
1412
+ 2. Run `rails generate support_desk:upgrade`, inspect the new migration, then
1413
+ `rails db:migrate` while writes remain paused. Fresh installs get the same
1414
+ migration from `support_desk:install`.
1415
+ 3. The migration seeds existing text messages at or before each role's old
1416
+ clock as the historical baseline. Messages beyond those clocks remain
1417
+ discoverable by recovery. **Old data cannot prove whether a message behind
1418
+ the clock lost its callback.** Review suspect historical cases explicitly;
1419
+ the migration does not replay old notifications or reopen history en masse.
1420
+ 4. Start only 0.3.2 processes. Run `SupportDesk.doctor` and
1421
+ `SupportDesk.redispatch_assistant_turns!` before resuming support traffic.
1422
+ Recovery checks closed cases as well: `:reopen_on_reply` reopens them, while
1423
+ `:locked` preserves closure and dispatches no model work. Registration repair
1424
+ also runs on desks with no assistant configured.
1425
+ 5. Resume writes. An assistant rollout still requires its own staging checks
1426
+ and disclosure decision. The serialization guarantee still requires
1427
+ PostgreSQL/MySQL; SQLite does not acquire row locks.
1428
+
1429
+ Do not roll back to an old writer while serving traffic: it would leave missing
1430
+ receipts. If a rollback is necessary, pause and drain first, keep the receipt
1431
+ table, and re-establish the historical baseline before a later upgrade. Removing
1432
+ a receipt deliberately permits processing that message again; it is an internal
1433
+ recovery action, not a normal host API.
1434
+
1435
+ Public assistant `turn:` arguments must be the observed opaque token. Symbols
1436
+ such as `:current` are refused; private outreach and automatic seat-taking do
1437
+ not expose a public bypass. No new host-facing registration API is required.
1438
+
1439
+ ### Historical upgrade from 0.2 to 0.3.0
1440
+
1441
+ The following describes the original assistants migration. When installing the
1442
+ current release, also follow the 0.3.2 cutover above.
1443
+
1444
+ ```bash
1445
+ rails generate support_desk:upgrade # current releases also copy the receipt migration
1446
+ rails db:migrate
1447
+ ```
1448
+
1449
+ Additive and rolling-safe — unlike 0.2, no drain. Deploy **every** process to
1450
+ 0.3 before you add `config.assistant`: a 0.2 worker cannot honour a turn it
1451
+ does not know about.
1452
+
1453
+ Four things change whether or not you configure an assistant:
1454
+
1455
+ - **A host model declared `acts_as_support_agent kind: :ai` is now refused
1456
+ for every support write, by it or to it** (`NotAnAssistant`). It used to
1457
+ be treated as a human. `kind:` is validated at declaration, and `doctor`
1458
+ warns about such classes. Only the desk's own `SupportDesk::Assistant` has
1459
+ machine authority.
1460
+ - **Console picker values are actor keys**, not bare ids — an assistant and
1461
+ a user can share an integer id. A bare id is still accepted for one
1462
+ release, and only when exactly one pool member matches it.
1463
+ - `Queue::TABS` gains `:needs_human`, hidden unless it is relevant.
1464
+ - **Every registered message and every transition writes
1465
+ `assistant_revision`**, on every case, with or without an assistant. It is
1466
+ one nullable integer, it is what the turn is made of, and a 0.2 process
1467
+ reading those rows is unaffected — which is why step 3 matters in the
1468
+ other direction: a 0.2 *writer* leaves the counter behind.
1469
+
1470
+ And when you do configure one: `desk.agents` becomes an Array of humans plus
1471
+ her and `desk.humans` is the human-only pool, `announce_assignments` never
1472
+ announces her, a human reply takes her case over under every `reply_policy`
1473
+ and supersedes the pending proposal, `close!` expires proposals, a reopen
1474
+ after her close caps her at `:draft`, and `reply!` / `post_agent_message!`
1475
+ accept `metadata:` and `turn:`.
1476
+
1477
+ `rose.deactivate!(by: owner)` is the kill switch: cross-process, within one
1478
+ transition, no deploy.
1479
+
494
1480
  ## The wizard
495
1481
 
496
1482
  "What do you need help with?" is a plain object, not a controller, so a host
@@ -527,12 +1513,100 @@ Pass `key:` from anywhere that runs more than once (a `to_prepare` block, an eng
527
1513
  Keep push titles and bodies generic — `ticket.notification_title` is safe for previews.
528
1514
  `ticket.notification_body` contains case details for an authenticated feed, not a lock screen.
529
1515
 
1516
+
1517
+
1518
+ The whole catalogue, with the arguments each subscriber receives:
1519
+
1520
+ | event | arguments | when |
1521
+ |---|---|---|
1522
+ | `ticket_opened` | `ticket` | a case was **inserted** — by a requester or by the desk; check `ticket.opened_by_support?` before paging your team about their own message |
1523
+ | `requester_replied` | `ticket, message` | a requester message registered, other than the one that opened the case |
1524
+ | `agent_replied` | `ticket, message` | a desk message registered, other than the desk's own opening message |
1525
+ | `ticket_assigned` | `ticket, assignment` | take, assign, or (later) routing — not the silent seat a desk-opened case starts with |
1526
+ | `ticket_handed_off` | `ticket, assignment, from:, note:` | |
1527
+ | `ticket_released` | `ticket, from:, reason:` | |
1528
+ | `ticket_closed` / `ticket_reopened` | `ticket, by:` | `by:` is the requester when their own reply reopened it |
1529
+ | `ticket_topic_changed` | `ticket, from:, to:, by:` | |
1530
+ | `subject_attached` | `ticket, subject, by:` | |
1531
+ | `note_added` | `ticket, event` | internal notes never reach the conversation |
1532
+ | `assistant_turn` | `ticket, assistant, message, turn:` | there is something for an assistant to answer — the only event a harness subscribes to (see [Assistants](#-assistants)) |
1533
+ | `draft_proposed` | `ticket, draft` | a proposal is waiting for a person |
1534
+ | `draft_sent` | `ticket, draft, message, by:` | |
1535
+ | `draft_rejected` | `ticket, draft, by:, reason:` | |
1536
+ | `assistant_withheld` | `ticket, assistant, reason:, policy:` | `respond!` wrote nothing, and the policy that refused is in the payload |
1537
+ | `ticket_escalated` | `ticket, from:, reason:, by:` | a case was handed to a person |
1538
+ | `human_requested` | `ticket, by:, reason:` | the requester pressed the door, or a `hand_off_when` phrase fired |
1539
+ | `assistant_paused` / `assistant_resumed` | `ticket, by:` | |
1540
+ | `ticket_transitioned` | `ticket, kind, by:, request:, payload:` | once per event row — the audit-log hook |
1541
+
1542
+ `ticket.agents_to_notify` is the assignee, or the whole on-duty pool while
1543
+ nobody holds the case — the gem computes it so every host gets "assignee or
1544
+ everyone" right. System messages (opening lines, closure notices) never emit
1545
+ anything and never move the clocks.
1546
+
1547
+ ## Errors
1548
+
1549
+ Every error inherits `SupportDesk::Error`, so `rescue SupportDesk::Error`
1550
+ catches anything support-specific; the console turns each into a translated
1551
+ flash. `ConfigurationError` (boot) · `ActorMissing` (a transition with no
1552
+ `by:` and no `Current.actor`) · `NotAnAgent` · `NotARequester` (no
1553
+ `has_support_tickets`, or its `if:` said no) · `NotTheAssignee` (a hand-off
1554
+ by somebody who doesn't hold the case) · `NotAllowed` (policy: a drop-in
1555
+ under `:assignee_only`, somebody else's record, a hidden topic, an agent
1556
+ writing to themselves) · `InvalidTransition` and its subclass `Locked` (a
1557
+ closed case on a desk that locks them, or a requester who can no longer be
1558
+ written to) · `UnknownTopic` · `NotSupportable` · `RateLimited` ·
1559
+ `TooManyOpenTickets`. With an assistant configured, three more:
1560
+ `NotAnAssistant` (an AI-kind actor that isn't this desk's own assistant — a
1561
+ subclass of `NotAnAgent`), `AssistantNotAllowed` (her policy forbids the
1562
+ verb; it carries the `policy` and the `verb`, so you can log the rule
1563
+ instead of parsing the sentence) and `StaleTurn` (the case changed since she
1564
+ read it — a subclass of `InvalidTransition`, so anything rescuing that
1565
+ still catches it).
1566
+
1567
+ ## Locales
1568
+
1569
+ `es` and `en` ship with the gem, under `support_desk.*` (requester screens,
1570
+ system lines, notifications, queue tabs, statuses, channels) and
1571
+ `support_desk.console.*` (queue, case page, compose form, flashes, errors).
1572
+ Your own locale files **outrank** the gem's — Rails loads every engine's
1573
+ locales first and the app's last — so override any key in your `es.yml` and
1574
+ the gem's copy loses. The ones hosts usually touch: `support_desk.topics.<path>.label`
1575
+ (and `.ask`, `.hint`), `support_desk.queue.tabs.*`,
1576
+ `support_desk.system.assigned`, `support_desk.thread.*`,
1577
+ `support_desk.console.flashes.*` and `support_desk.console.errors.*`. The
1578
+ suite asserts both languages carry exactly the same keys.
1579
+
1580
+ ## Doctor
1581
+
1582
+ ```ruby
1583
+ SupportDesk.doctor.print # or .ok? in CI
1584
+ ```
1585
+
1586
+ Configuration: `requester_class`, `agents` (the block resolves to records),
1587
+ `topics` (a tree with a way out), `opening lines` (every static line
1588
+ interpolates, every I18n key exists), `supportables` (every `about:` class is
1589
+ supportable), `find_requester` (callable, one argument), `engine mount`,
1590
+ `parent controllers`. Chats seams: `chats subscribers`, `chats authorship`,
1591
+ `desk messager`. Data invariants: `conversations` (every ticket has one),
1592
+ `assignments` (at most one open per case), `assignee pointers`, `provenance`
1593
+ (no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
1594
+ task), `awaiting` (agrees with the transcript), `references` (unique).
1595
+ Assistants (only where one is configured): `assistants (config)`,
1596
+ `assistant turn subscriber`, `assistant serialization` — which warns when
1597
+ the adapter takes no row locks, because the turn rests on them —
1598
+ `assistant authorship`, `assistant silence`, `assistant seats`, `assistant
1599
+ idle turns`, `drafts`, and `ai agents without policy`, which warns about any
1600
+ host class declared `kind: :ai`, since every support write by it is refused.
1601
+ The seat and draft checks also run once she is no longer configured, since
1602
+ that is exactly when a seat gets stranded.
1603
+
530
1604
  ## Compatibility
531
1605
 
532
1606
  Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`).
533
1607
 
534
- PostgreSQL and SQLite enforce unique new-case creation and one open assignment per ticket
535
- with **partial unique indexes**. New submissions reuse an existing open case. Reopening
1608
+ PostgreSQL and SQLite enforce unique new-case creation, one open assignment per ticket and
1609
+ one pending proposal per ticket with **partial unique indexes**. New submissions reuse an existing open case. Reopening
536
1610
  historical cases is deliberately exempt from new-case deduplication: if a newer case
537
1611
  already exists, both histories remain open and support is notified of the reply. No
538
1612
  conversation is silently merged, closed or discarded. MySQL has no partial indexes,
@@ -558,6 +1632,7 @@ bundle exec appraisal rails-8.1 rake test
558
1632
  include SupportDesk::TestHelpers
559
1633
 
560
1634
  ticket = open_support_ticket(for: users(:alice), about: orders(:one), message: "…")
1635
+ written = open_support_ticket(for: users(:alice), by: users(:lucia), message: "…") # the desk writes first
561
1636
  reply_as users(:lucia), ticket, "…"
562
1637
  assert_awaiting_requester ticket
563
1638
  assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
@@ -565,6 +1640,48 @@ assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
565
1640
  with_support_config(reply_policy: :assignee_only) { … }
566
1641
  ```
567
1642
 
1643
+ Everything the module gives you:
1644
+
1645
+ | helper | |
1646
+ |---|---|
1647
+ | `open_support_ticket(for:, message:, about:, topic:, by:)` | a case, the way a requester opens one — or the desk, with `by:` |
1648
+ | `reply_as(agent, ticket, body, files:)` | answer; returns the `Chats::Message` |
1649
+ | `ask_again(ticket, body)` | the requester writes again |
1650
+ | `assert_awaiting_reply` / `assert_awaiting_requester` | who owes the next word (these reload the ticket) |
1651
+ | `assert_ticket_open` / `assert_ticket_closed` | status |
1652
+ | `assert_assigned_to(ticket, agent)` / `assert_unassigned` | the seat |
1653
+ | `assert_ticket_event(ticket, kind, from:, to:, by:)` / `refute_ticket_event` | the timeline |
1654
+ | `with_support_config(desk = :default, **overrides) { … }` | different desk settings for one block, put back afterwards |
1655
+ | `capture_support_events(*names) { … }` | `[[name, args, kwargs], …]` of what the block emitted; unsubscribes on the way out |
1656
+
1657
+ And, with an assistant configured, the ones its own suite uses — `support_assistant`, `respond_as`, `draft_as`, `assert_pending_draft` / `refute_pending_draft`, `assert_needs_human` / `refute_needs_human`, `assert_held_by_assistant`, `refute_assistant_spoke`, `assert_assistant_policy`, `with_assistant_config` and `with_topic_assistant_cap`. They are documented in [Assistants](#-assistants).
1658
+
1659
+ Between examples, `SupportDesk.reset!` clears configuration, subscribers,
1660
+ desks and registries; `SupportDesk.reset_desks!` only forgets the memoised
1661
+ desk records.
1662
+
1663
+ ## Module-level API
1664
+
1665
+ ```ruby
1666
+ SupportDesk.configure { |config| … } SupportDesk.config SupportDesk.configured?
1667
+ SupportDesk.desk(key = :default) # the Desk record, found or created, memoised
1668
+ SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1669
+ SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
1670
+ SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
1671
+ SupportDesk.reclaim_assistant_seats! # seats an assistant may no longer sit in
1672
+ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
1673
+ SupportDesk.find_topic("billing/invoice")
1674
+ SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
1675
+ SupportDesk.doctor
1676
+ SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
1677
+ SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language
1678
+ SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
1679
+ SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
1680
+ SupportDesk.eligible?(record, condition) # how both macros read their `if:`
1681
+ SupportDesk.subscribe_to_chats! # the `:message_created` listener; idempotent
1682
+ SupportDesk::VERSION
1683
+ ```
1684
+
568
1685
  ## Development
569
1686
 
570
1687
  After checking out the repo, run `bundle install`, then `bundle exec rake ci`. The dummy app lives in `test/dummy` and mounts all three surfaces the way a real host does: the requester engine at `/messages/support`, `chats` at `/messages`, and the turnkey console at `/admin/support` — plus the same console again inside a host-owned `madmin` namespace, because "the console uses only the public API" is a claim that needs a second implementation to be worth anything.