support_desk 0.3.0 → 0.4.0

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +206 -0
  3. data/README.md +227 -16
  4. data/app/controllers/support_desk/application_controller.rb +5 -2
  5. data/app/controllers/support_desk/tickets_controller.rb +68 -3
  6. data/app/helpers/support_desk/engine_helper.rb +10 -10
  7. data/app/helpers/support_desk/thread_helper.rb +29 -0
  8. data/app/views/support_desk/tickets/show.html.erb +8 -0
  9. data/config/locales/support_desk.en.yml +28 -0
  10. data/config/locales/support_desk.es.yml +28 -0
  11. data/lib/generators/support_desk/install_generator.rb +9 -3
  12. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  13. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  14. data/lib/generators/support_desk/upgrade_generator.rb +15 -7
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +100 -19
  17. data/lib/support_desk/conversation_access.rb +42 -0
  18. data/lib/support_desk/doctor.rb +30 -3
  19. data/lib/support_desk/engine.rb +13 -1
  20. data/lib/support_desk/message_signatures.rb +20 -0
  21. data/lib/support_desk/models/assistant.rb +43 -8
  22. data/lib/support_desk/models/draft.rb +9 -0
  23. data/lib/support_desk/models/message_registration.rb +12 -0
  24. data/lib/support_desk/models/ticket/assistance.rb +254 -30
  25. data/lib/support_desk/models/ticket.rb +172 -67
  26. data/lib/support_desk/transcript.rb +9 -12
  27. data/lib/support_desk/version.rb +1 -1
  28. data/lib/support_desk/wizard.rb +1 -5
  29. data/lib/support_desk.rb +82 -11
  30. data/lib/tasks/support_desk.rake +12 -1
  31. metadata +20 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d7e90a546ededcbf2f0f13d30a2929655e0d6bb343f11d114b0e539b8985bfb
4
- data.tar.gz: 502d37cedab338e5f42db97b021ed01dd8ee46844600fc31d3240e9a1a09597c
3
+ metadata.gz: afefc82399ac7abf6a331cc44fceb5fd160694fea8c83025af5e5888399a18d2
4
+ data.tar.gz: c209e7f207e89de6e4e0b48ecdd8b2dfd4e7011b352cf2eaf52c82dd9b13260f
5
5
  SHA512:
6
- metadata.gz: fae5879d3023eed0f45c24e275bf63fdaf5ac2f8e982a7d6b9ea6c9db4afe12036025c6ef20a1280031456978908414849719fb9ea28a6d53d5dd47a3c6995aa
7
- data.tar.gz: 9e8e5df4a7e7f836c297a3509d4c2d23111a6f9365fdd34208e410db11e249aa0445c515fe19421efa4f883b43503cf65bc32efc207007ff46a55d9102b8a470
6
+ metadata.gz: 773a60b0710143ef412597a9789b2c37e5a41d99cbb965e5908d6035c11dcd5d97b0353c4f0777e5fd1e7644414ad01afa7c19379402a83e3cb9ef81353d0896
7
+ data.tar.gz: 120b9347b36642f5248ba4c6e95b1898886f666913729cedc28d02b0177e3a97c5136e12ab8052501921a2b812488d55359ed326ac361dd5cb9c5af76b1ff55f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,212 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - **Support apart from the inbox.** `config.thread_page = :support` reads a
12
+ case on the engine's own page, `/support/tickets/T-AB12CD`, which renders
13
+ chats' thread template (same bubbles, composer and realtime) with its way
14
+ back leading to the support list. `support_thread_path`, the list rows and
15
+ the redirect after opening a case follow the setting. The default,
16
+ `:chats`, is unchanged. A third Hotwire Native rule pushes the ticket page
17
+ under `:support`.
18
+ - **A requester's own case always opens through chats.** chats finds every
19
+ thread and composer request through `viewer.chats`, which applies the
20
+ host's `inbox_scope`; a host keeping support out of its inbox would 404 its
21
+ requesters inside their own cases. The engine adds the viewer's own cases
22
+ to chats' `conversation_lookup_scopes` (`SupportDesk::ConversationAccess`,
23
+ `SupportDesk::Ticket.conversations_of`): by participation and blocks,
24
+ without the listing scope, each a direct conversation whose subject is that
25
+ very case. Nothing else the scope hides becomes reachable.
26
+ - **`acknowledgement`**, a desk setting: the system line posted right under a
27
+ requester's first message, in the same transaction, and read for them as
28
+ it is posted (a receipt for their own words is never news). Same values and
29
+ interpolations as `opening_line`; nil by default, and the gem ships
30
+ `support_desk.thread.acknowledgement` to opt into.
31
+ - An I18n key given as a line reads its `<key>_with_promise` variant when the
32
+ desk promises a time and the variant exists (the assistant lines already
33
+ did): the shipped acknowledgement is "Thanks, we got it." on a desk with
34
+ `reply_within = nil`, and adds the promise otherwise.
35
+ - `SupportDesk.humanize_promise` and `DeskConfiguration#reply_promise`: the
36
+ promise in the unit the desk wrote it in, from the new
37
+ `support_desk.durations.*` keys.
38
+ - `support_desk.thread.closed_notice` can interpolate `%{reply_within}`,
39
+ `%{desk}` and `%{label}`.
40
+
41
+ ### Changed
42
+
43
+ - The answer promise (`support_reply_promise`, the wizard's line, and
44
+ `%{reply_within}` in every line) is now said in the unit `reply_within`
45
+ was configured in: `24.hours` reads "24 hours" where it read "1 day", and
46
+ `8.hours` reads "8 hours" where it read "about 8 hours". A duration of more
47
+ than one unit, or a fractional one, reads as before. Waits in the console
48
+ are unchanged.
49
+
50
+ ### Fixed
51
+
52
+ - `SupportDesk::ConversationAccess` adds a requester's own cases as a lookup
53
+ scope (`conversation_lookup_scopes`, chats 0.5.1) instead of rescuing chats'
54
+ 404: no exception is inspected, and a host's own `find_conversation` that
55
+ refuses a case is final. It only ever adds a scope beside the host's: a host
56
+ that returns no scopes for a viewer (a suspended account) opens nothing for
57
+ them, their cases included.
58
+ - The ticket page resolves its conversation through chats' own
59
+ `find_conversation` (a chats controller bound to the request), so whatever
60
+ refuses a case at the chats URL refuses it on the ticket page too.
61
+ - The access fallback and the ticket page require the case and its
62
+ conversation to point at each other (a direct conversation whose subject is
63
+ that case): a ticket mislinked to a group or to another conversation opens
64
+ nothing.
65
+ - The ticket page resolves its conversation through chats' participation and
66
+ block rules: owning the case is no longer enough once the seat is gone.
67
+ - The support list's "showing the N most recent" line under a long closed
68
+ section (`support_desk.tickets.closed_capped`) had no copy in English or
69
+ Spanish, so a requester with more than 20 closed cases read "translation
70
+ missing".
71
+
72
+ ### Compatibility
73
+
74
+ - Requires chats >= 0.5.1; take both from GitHub by tag until they are on
75
+ rubygems. (The old `~> 0.3, >= 0.3.2` already admitted 0.4 and 0.5: `~> 0.3`
76
+ is `>= 0.3, < 1.0`.) 0.5.1 brings the lookup seam the access to a
77
+ requester's own case is built on. The whole suite runs on chats 0.5.1, the
78
+ reviewed package in `vendor/cache` built from tag v0.5.1 (6159ea1). The
79
+ chats 0.4.2 CI leg is gone: the constraint no longer admits it. Every CI job
80
+ asserts the chats it resolved (`CHATS_VERSION_UNDER_TEST`), so a later chats
81
+ release never silently becomes what CI tests. The dummy app's chats
82
+ migration is chats' current install template.
83
+
84
+ ## [0.3.2] - Unreleased
85
+
86
+ ### Fixed
87
+
88
+ - Persist message-registration receipts atomically with clocks and turn revisions.
89
+ A new message with an earlier timestamp or smaller UUID is no longer dropped
90
+ as a replay. It invalidates the turn and triggers requester notifications and
91
+ handoff detection once, without rewinding clocks. This also fixes human-only
92
+ desks; no assistant configuration is needed to encounter the old defect.
93
+ - Recovery discovers unregistered replies on closed cases and honors the desk's
94
+ reopen/locked policy. Repair also runs when the assistant is disabled; only
95
+ actual assistant dispatch requires one to be configured.
96
+ - Public assistant assignment, release and reply refuse `turn: :current`.
97
+ Internal outreach and seat-taking use private paths and actual tokens.
98
+ - Default Chats signatures render assistant messages using per-message
99
+ disclosure snapshots, including old 0.3.0 messages with provenance but no
100
+ assistant-row snapshot. Rename, mode change and flag-off do not erase old
101
+ disclosure. Human replies and approved drafts keep human signatures, and host
102
+ signature overrides remain authoritative. Transcript names are snapshotted
103
+ for new messages; older ones use their stored display name.
104
+ - Correct the README signature override to preserve the human fallback.
105
+
106
+ ### Upgrade
107
+
108
+ Run `rails generate support_desk:upgrade` and migrate **with support writes paused
109
+ and all old web/jobs drained**. The new receipt table follows the existing ticket
110
+ and message ID types and seeds historical messages up to the old role clocks.
111
+ Old clocks cannot reveal lost callbacks behind that baseline: review suspect
112
+ historical cases before resuming. Only 0.3.2 processes may write after cutover.
113
+ See README for recovery, rollback limitations and the unchanged SQLite caveat.
114
+ No gem publication or deployment is performed by this change.
115
+
116
+ ## [0.3.1] - 2026-09-19
117
+
118
+ **Hardening the assistant, after an adversarial review of 0.3.0.** Nine
119
+ defects, every one of them in the concurrency, recovery and kill-switch
120
+ guarantees the release advertised rather than in what a green suite
121
+ covered. Nothing here changes what an assistant may do; it changes what
122
+ can happen to a case while she is doing it. `assign!` and `release!` take
123
+ a `turn:` when `by:` is an assistant — the one API change — and a host
124
+ with no assistant is untouched.
125
+
126
+ ### Fixed
127
+
128
+ - **A customer's next question can no longer be answered around (R1).**
129
+ Reconciliation is a `SELECT`, and a requester never takes the case's row
130
+ lock, so a question could commit between the answer reading the case and
131
+ the answer writing to it: she replied to the previous one, and a human
132
+ approved a proposal from a page that predated the new one — `seen_turn`
133
+ was not evidence that the reviewer had seen the last committed message.
134
+ Every speaking path now takes the ticket's row lock and then the
135
+ conversation row chats updates inside every message insert, before
136
+ reconciling. The lock order is ticket → conversation everywhere, and it
137
+ is written down. **The guarantee is a row lock, so it holds on PostgreSQL
138
+ and MySQL and not on SQLite**, which has no row locks and whose WAL
139
+ snapshot reads straight through an open write transaction: a desk with an
140
+ assistant belongs on PostgreSQL or MySQL, and `doctor` now warns when it
141
+ is not.
142
+ - **A seat is decided under the lock (R2).** `assign!` asked whether
143
+ anybody held the case, and whether she could hold it, *before* the
144
+ transition acquired the row. No threads were needed to break it: load an
145
+ unassigned case into one instance, give it to a person through another,
146
+ and the first instance took the seat from them. A topic cap or a pause
147
+ committed while a take waited for the lock was read too late in the same
148
+ way.
149
+ - **A lost registration is repairable (R3).** A requester message that
150
+ committed while the process that should have registered it died left a
151
+ turn nothing could ever consume: reconciliation happened inside the
152
+ candidate answer's savepoint, the bumped revision made that very answer
153
+ stale, and the `StaleTurn` rolled the repair back with it — for ever,
154
+ while the clocks said `awaiting_requester` so no idle query could see the
155
+ case. The fold is now its own committed step, and
156
+ `redispatch_assistant_turns!` looks for unregistered messages before it
157
+ looks at clocks.
158
+ - **Messages that share an instant no longer deadlock the turn (R4).** The
159
+ unseen-message test was "its id is not the one the clock was set from",
160
+ so every other message on that timestamp read as new: reconciliation
161
+ folded an already-registered one in again, bumped the revision, and made
162
+ the answer that discovered it stale. The retry did the same thing. "Seen"
163
+ is now the pair `(created_at, id)` compared in chats' own transcript
164
+ order, by the same rule in Ruby and in SQL.
165
+ - **The silent sweep rechecks before it escalates (R5).** Its predicates
166
+ were true when it SELECTED its candidates. A person who answered, took
167
+ the case or reset the clock in between lost anyway: the case was marked
168
+ human-required, its priority went up, and the customer was told a person
169
+ was coming — on a case that already had one.
170
+ - **Stranded seats come back (R6).** `deactivate!` promised the sweep would
171
+ release her cases; the sweep only ever visited configured assistants with
172
+ a `responds_within`, and only cases already overdue. An assistant with no
173
+ promise kept her seat for ever, and one removed by the documented kill
174
+ switch — which takes her configuration away — was not visited at all,
175
+ while the doctor's assistant checks returned early on exactly that state.
176
+ `SupportDesk.reclaim_assistant_seats!` (and `rake
177
+ support_desk:reclaim_assistant_seats`) reads the seats that exist.
178
+ - **Running out of turns tells the host (R7).** The budget branch wrote the
179
+ `escalated` event, released her seat and posted the public hand-off line,
180
+ but only the outer `escalate!` published `:ticket_escalated` — so a
181
+ host's "a person is needed here" notifier never heard about a
182
+ conversation that ran out of turns. One private method now publishes it
183
+ for every hand-off.
184
+ - **Disclosure on old messages stops moving (R8).** The requester-facing
185
+ signature resolved live configuration, so taking her out of the
186
+ initializer rewrote "— Rose · asistente virtual" into "— Rose" on
187
+ messages nobody had touched. Her name and mode are snapshotted onto her
188
+ own row and read back from it when nothing declares her any more. A
189
+ rename still renames history, deliberately; the README shows the
190
+ `Chats.config.message_signature` lambda for hosts that want each bubble
191
+ frozen.
192
+ - **The kill switch is read after the wait (R9).** Eligibility was checked
193
+ before the case's row lock, and the policy then read `active?` off that
194
+ same stale copy, so an already-seated assistant whose job queued behind a
195
+ busy case could still speak after `deactivate!` had committed. Every verb
196
+ resolves her under the lock now, from the row. The ordinary in-flight
197
+ window — a switch that commits after that read — is documented rather
198
+ than claimed away.
199
+
200
+ ### Changed
201
+
202
+ - `Ticket#assign!` and `Ticket#release!` take `turn:`. It is REQUIRED when
203
+ `by:` is an assistant (`ArgumentError` when omitted, `StaleTurn` when
204
+ stale) and ignored for a person, whose calls are unchanged.
205
+ - `SupportDesk.release_silent_assistants!` runs
206
+ `reclaim_assistant_seats!` first, and its count includes what that
207
+ reclaimed.
208
+ - The doctor's assistant invariants run whenever an assistant ROW exists,
209
+ not only while one is configured.
210
+ - `Ticket.with_unregistered_requester_messages` — the scope behind the
211
+ repair half of redispatch.
212
+
7
213
  ## [0.3.0] - 2026-09-19
8
214
 
9
215
  **A machine can answer, and a person still owns every word it sends.** An
data/README.md CHANGED
@@ -153,6 +153,7 @@ configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
153
153
  | `current_agent_method` | `:current_user` | how the console finds the person answering (or define `current_agent` in your controller) |
154
154
  | `authenticate_method` | `:authenticate_user!` | your own filter, run before every requester screen |
155
155
  | `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
156
+ | `thread_page` | `:chats` | where a requester reads a case: `:chats` (its thread in the chats inbox) or `:support` (this engine's own ticket page, for support kept apart from the inbox; see [Support apart from the inbox](#support-apart-from-the-inbox)) |
156
157
  | `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
158
  | `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
158
159
  | `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
@@ -185,7 +186,7 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
185
186
  | `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
187
  | `announce_assignments` | `:first_only` | `:first_only` ("Lucía is taking care of your request" once) · `:always` (hand-offs too) · `:never` |
187
188
  | `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
+ | `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads, said in the unit you write it in (`72.hours` reads "72 hours") |
189
190
  | `at_risk_after` | `4.hours` | when a waiting case starts showing as at risk |
190
191
  | `open_rate_limit` | `{ to: 5, within: 1.hour }` | how often one requester may **ask**; `nil` disables. Counts only cases the requester opened |
191
192
  | `max_open_tickets` | `5` | how many cases one requester may have open; `nil` disables. Same rule |
@@ -195,6 +196,7 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
195
196
  | `auto_close_after` | `nil` | reserved for the sweep job: a duration after which an answered case closes itself |
196
197
  | `opening_line` | `nil` | the system line a requester-opened thread starts with (see [Writing first](#writing-first)) |
197
198
  | `opening_line_from_support` | the gem's I18n copy | the same line for a case the desk opened |
199
+ | `acknowledgement` | `nil` | the system line posted right under a requester's first message ("Thanks, we got it…"; see [Writing first](#the-line-a-thread-opens-with)) |
198
200
  | `find_requester { \|query\| … }` | `nil` | how a console turns what an agent typed into a requester record |
199
201
 
200
202
  `SupportDesk.doctor.print` checks all of it against a running app (see
@@ -410,7 +412,7 @@ mount SupportDesk::Engine => "/support" # CarHey mounts it at "/messages/suppo
410
412
  | `GET /support` | their cases — open ones as chats rows, closed ones folded away, and the door into a new one |
411
413
  | `GET /support/new` | the wizard: pick a topic, pick the thing, write. One URL, three Turbo frames |
412
414
  | `POST /support/tickets` | `ask_support!`, then straight into the conversation |
413
- | `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too), redirecting to its thread |
415
+ | `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too): a redirect to its thread, or the page it is read on under `thread_page = :support` |
414
416
 
415
417
  The screens inherit `config.parent_controller`, so your layout, your
416
418
  authentication (`config.authenticate_method`) and your locale switching all
@@ -440,6 +442,54 @@ door in its place (`config.inbox_entry = :always | :when_tickets | :never`) —
440
442
  a support entry that only exists once you already have a ticket is
441
443
  undiscoverable.
442
444
 
445
+ ### Support apart from the inbox
446
+
447
+ Some products want support to be a place of its own: a Support button that
448
+ leads to the person's cases, rather than an official row among their
449
+ conversations. Three settings say so:
450
+
451
+ ```ruby
452
+ # config/initializers/support_desk.rb
453
+ config.thread_page = :support # a case is read at /support/tickets/T-AB12CD
454
+ config.inbox_entry = :never # no door in the chats inbox
455
+
456
+ # config/initializers/chats.rb: support conversations out of the inbox, the unread badge and everything else that lists
457
+ config.inbox_scope = ->(relation, _viewer) {
458
+ relation.where(subject_type: nil).or(relation.where.not(subject_type: "SupportDesk::Ticket"))
459
+ }
460
+ ```
461
+
462
+ (`subject_type: nil` keeps the conversations that are about nothing, which is
463
+ most of them: `where.not` alone would hide those too.)
464
+
465
+ The ticket page **is chats' thread**: the engine renders chats' own template,
466
+ so the bubbles, the composer, read state, typing and realtime are the ones
467
+ every other conversation has, with the desk's verified name in the header.
468
+ Only the way back differs: it leads to the support list. Older pages, the
469
+ composer and the live updates still go to chats' endpoints.
470
+
471
+ Those endpoints find a conversation through the relations chats'
472
+ `conversation_lookup_scopes` returns (chats >= 0.5.1), by default the viewer's
473
+ inbox with the host's `inbox_scope` applied, so a scope that keeps support out
474
+ of the inbox would also keep a requester out of their own case. The engine
475
+ adds one relation after chats' own (`SupportDesk::ConversationAccess`): the
476
+ viewer's **own** cases (`SupportDesk::Ticket.conversations_of`), through the
477
+ same participation and block rules (`Chats::Conversation.inbox_for`) without
478
+ the listing scope, each a direct conversation whose subject is that very
479
+ case. Nobody gains a conversation they are not seated in, anything else the
480
+ scope hides stays a 404, and a host's own `find_conversation` that refuses a
481
+ case still refuses it. support_desk only ever adds a scope beside the host's:
482
+ a host that must refuse a viewer entirely returns no scopes, and then nothing
483
+ opens for them, their cases included. The ticket page asks chats the same
484
+ question (chats' own `find_conversation`), so it never shows what the chats
485
+ URL would refuse.
486
+
487
+ Links follow the setting: `support_thread_path`, the list's rows and the
488
+ redirect after a case is opened all lead to the ticket page, by reference.
489
+ Telling the requester an answer arrived is then yours, since the chats
490
+ badge no longer counts it: subscribe to `agent_replied` (see [Events](#events))
491
+ and link the notification to `support_desk.ticket_path(ticket.reference)`.
492
+
443
493
  Hotwire Native hosts merge the engine's path rules into their own, AFTER any
444
494
  rule that could swallow them:
445
495
 
@@ -462,7 +512,7 @@ URL, so the back gesture and cold-boot deep links work.
462
512
  | `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
513
  | `support_unread_badge` | unread support messages, counted against the chats read horizon |
464
514
  | `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
465
- | `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` |
515
+ | `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` (also `SupportDesk.config.desk(key).reply_promise`) |
466
516
  | `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
467
517
  | `support_inbox_door?(viewer)` | whether the inbox should show the door instead of a row (`inbox_entry`) |
468
518
  | `support_desk_record(key)`, `support_desk_avatar`, `support_desk_badge` | the desk as a counterpart: record, avatar (initials fallback), verified badge |
@@ -682,6 +732,33 @@ existing threads open exactly as they do today; `opening_line_from_support`
682
732
  has the gem's own copy as its default, because a message from a desk
683
733
  somebody never wrote to has to explain itself.
684
734
 
735
+ And one line goes **under** the first message, for a case the requester
736
+ opened: the answer to "did that go through?", posted in the same
737
+ transaction, before anybody on the desk has read it.
738
+
739
+ ```ruby
740
+ config.acknowledgement = :"support_desk.thread.acknowledgement"
741
+ # => "Thanks, we got it. We usually reply in under 72 hours."
742
+ ```
743
+
744
+ An I18n key reads its `<key>_with_promise` variant when the desk promises a
745
+ time and the variant exists (the shipped key has one), so a desk with
746
+ `reply_within = nil` says "Thanks, we got it." and nothing it cannot keep.
747
+ The requester's read horizon moves through the acknowledgement as it is
748
+ posted: it is a receipt for what they just wrote, never news to them.
749
+
750
+ Same values and interpolations as the opening lines, nil by default. It
751
+ never moves the clocks (it is a system message), it is pinned one database
752
+ tick after the message when the clock did not move between the two inserts,
753
+ and it is then the conversation's last message, which is what a list row
754
+ previews. A reply into a case already open is not acknowledged again.
755
+
756
+ `%{reply_within}` is the promise in the unit it was configured in
757
+ (`SupportDesk.humanize_promise`: `72.hours` is "72 hours", never "3 days"),
758
+ from `support_desk.durations.*`. The closed notice
759
+ (`support_desk.thread.closed_notice`) takes the same three interpolations,
760
+ so a locked case can say how soon a new one will be answered.
761
+
685
762
  ### From the console
686
763
 
687
764
  Add `new` to your routes and the queue grows a "Write to someone" button:
@@ -942,6 +1019,13 @@ one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
942
1019
  also why this gem has no idempotency keys, no claim rows and no leases —
943
1020
  "is this still the case you read?" is already answered.
944
1021
 
1022
+ Every verb of hers takes it, and that includes taking and giving back the
1023
+ seat: `assign!(to: rose, by: rose, turn:)` and `release!(by: rose, turn:)`
1024
+ require it when `by:` is an assistant (an omitted turn is an
1025
+ `ArgumentError`, a stale one a `StaleTurn`), because a run that finished
1026
+ after the case moved on must not release the seat a newer one took. A
1027
+ **person's** calls are unchanged and take no turn.
1028
+
945
1029
  Two consequences worth knowing:
946
1030
 
947
1031
  - Check `ticket.assistant_turn == turn` in your job **before you spend
@@ -949,6 +1033,34 @@ Two consequences worth knowing:
949
1033
  - A retry after a committed action is a `StaleTurn`, and that is correct:
950
1034
  the work was done. Generated jobs `discard_on` it.
951
1035
 
1036
+ #### Ticket, then conversation
1037
+
1038
+ A turn is only as good as the reconciliation behind it, and reconciliation
1039
+ is a `SELECT`: on its own it cannot exclude a message that commits a
1040
+ millisecond later. Your customer never takes the case's row lock — they
1041
+ press send, and chats writes a message.
1042
+
1043
+ What chats *does* take is the **conversation row**: every message insert
1044
+ updates `chats_conversations` inside its own transaction. So every path
1045
+ that speaks takes the ticket's row lock and then that row, before
1046
+ reconciling. A question already in flight holds it, so we wait for it and
1047
+ the turn goes stale; a question that starts after we hold it waits for us
1048
+ and raises a turn of its own. **The order is always ticket → conversation**,
1049
+ in this gem and in anything you add to it.
1050
+
1051
+ What that does not cover: a message whose `INSERT` was already stamped when
1052
+ we won the row is still stamped earlier than the answer, so a transcript can
1053
+ show a question above an answer that did not address it. That is a
1054
+ genuinely simultaneous send, and its own turn follows.
1055
+
1056
+ > [!IMPORTANT]
1057
+ > **This is a row lock, so it is PostgreSQL and MySQL.** SQLite has none:
1058
+ > it serializes writes, and in WAL mode a reconciliation `SELECT` reads the
1059
+ > last committed snapshot straight through a requester's open write
1060
+ > transaction, so a question committing behind an answer is still missed
1061
+ > there. Run PostgreSQL or MySQL for a production desk with an assistant —
1062
+ > `SupportDesk.doctor` warns when you haven't.
1063
+
952
1064
  ### Proposals in the console
953
1065
 
954
1066
  A pending proposal renders above the composer with its confidence, its
@@ -1020,6 +1132,32 @@ the turn and the whole policy that allowed it, `ticket.export` labels it
1020
1132
  marks it for staff. What a customer is told is a product decision; what your
1021
1133
  records say a machine wrote is not.
1022
1134
 
1135
+ **Historical disclosure is captured per message.** The default Chats signature
1136
+ renderer uses the assistant name and disclosure stored when the message was
1137
+ sent. Renaming, changing mode or removing configuration does not rewrite old
1138
+ signed bubbles. Human replies and human-approved drafts retain their normal
1139
+ human signatures. The assistant row snapshot is a fallback for legacy messages
1140
+ without provenance; their original disclosure cannot be reconstructed if it
1141
+ was never stored.
1142
+
1143
+ A host's `Chats.config.message_signature` override remains authoritative. If
1144
+ you customize it, preserve the human fallback as well as the assistant snapshot:
1145
+
1146
+ ```ruby
1147
+ Chats.configure do |config|
1148
+ config.message_signature = lambda do |message|
1149
+ stamp = message.metadata["support_desk"]
1150
+ name = if stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true &&
1151
+ stamp["display_name"].is_a?(String) && stamp["display_name"].present?
1152
+ stamp["display_name"]
1153
+ else
1154
+ Chats.display_name_for(message.author)
1155
+ end
1156
+ I18n.t("chats.message.signature", name: name)
1157
+ end
1158
+ end
1159
+ ```
1160
+
1023
1161
  ### Humans outrank
1024
1162
 
1025
1163
  - A person may answer a case she holds under **every** `reply_policy`.
@@ -1071,8 +1209,27 @@ whatever she would have said. `redispatch_assistant_turns` re-emits the turn
1071
1209
  for cases nobody acted on, which is safe precisely because the turn is
1072
1210
  consumed by the first action and every later one is a `StaleTurn`.
1073
1211
 
1074
- `rake support_desk:assistant_status` reads and writes nothing, and is the
1075
- line to put in a deploy check. `SupportDesk.doctor` covers the same ground
1212
+ `release_silent_assistants` runs `SupportDesk.reclaim_assistant_seats!`
1213
+ first, and you can run that on its own
1214
+ (`rake support_desk:reclaim_assistant_seats`). It is a different question
1215
+ from silence: it reads the **seats that exist** rather than the assistants
1216
+ this process happens to have configured, and gives back every one whose
1217
+ holder is switched off, no longer declared, or no longer allowed to hold a
1218
+ case — with no `responds_within` and no overdue clock anywhere in it. That
1219
+ is what makes `deactivate!` and a flag flipped off actually release her
1220
+ cases, and it is why the doctor's seat checks keep running when the
1221
+ configuration is gone.
1222
+
1223
+ `redispatch_assistant_turns` also repairs before it decides: a requester
1224
+ message whose registration was lost after its commit leaves the clocks
1225
+ describing a case that no longer exists, so the task looks for unregistered
1226
+ **messages** and not only for idle clocks. The repair commits on its own,
1227
+ which is what makes a dead process followed by nothing but this task end in
1228
+ an actionable turn.
1229
+
1230
+ `rake support_desk:assistant_status` reports current counts without changing
1231
+ tickets. Resolving configured assistants may create their identity rows or
1232
+ refresh their name/disclosure snapshots. `SupportDesk.doctor` covers the same ground
1076
1233
  with verdicts.
1077
1234
 
1078
1235
  ### What the model sees
@@ -1166,7 +1323,8 @@ acts_as_support_agent kind: :ai # validated; see
1166
1323
  SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1167
1324
  SupportDesk.reset_assistants!
1168
1325
  SupportDesk.ai_actor?(record)
1169
- SupportDesk.release_silent_assistants! # → Integer
1326
+ SupportDesk.release_silent_assistants! # → Integer (runs reclaim_assistant_seats! first)
1327
+ SupportDesk.reclaim_assistant_seats! # → Integer; seats she may no longer sit in
1170
1328
  SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
1171
1329
  ```
1172
1330
 
@@ -1176,7 +1334,7 @@ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
1176
1334
  |---|---|
1177
1335
  | `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
1178
1336
  | `config` / `configured?` | her slice of the configuration; whether anything still declares her |
1179
- | `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration, with safe answers when it is gone |
1337
+ | `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 |
1180
1338
  | `disclosed?` `signs?` `notice?` | the mode, as predicates |
1181
1339
  | `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
1182
1340
  | `on_duty?` `support_capacity` | the agent contract |
@@ -1201,6 +1359,8 @@ assistant" it was).
1201
1359
  respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
1202
1360
  draft!(body, by:, turn:, …) # → Draft
1203
1361
  escalate!(by:, reason:, summary:, turn:, request:)
1362
+ assign!(to:, by:, reason:, note:, request:, turn:) # turn: required when by: is an assistant
1363
+ release!(by:, reason: :released, request:, turn:) # idem
1204
1364
  request_human!(by:, request:) # by: must be the requester
1205
1365
  pause_assistant!(by:, reason:) / resume_assistant!(by:)
1206
1366
 
@@ -1213,6 +1373,7 @@ drafts pending_draft notes
1213
1373
  # scopes
1214
1374
  held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
1215
1375
  resolved_by_assistant with_pending_draft assistant_idle_since(time)
1376
+ with_unregistered_requester_messages
1216
1377
  ```
1217
1378
 
1218
1379
  Columns: `assistant_revision`, `last_requester_message_id`,
@@ -1288,12 +1449,13 @@ the count is non-zero.
1288
1449
  `support_human_door(ticket)` helper.
1289
1450
 
1290
1451
  **Rake** — `support_desk:release_silent_assistants` ·
1452
+ `support_desk:reclaim_assistant_seats` ·
1291
1453
  `support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
1292
1454
  `support_desk:assistant_status`.
1293
1455
 
1294
1456
  **Doctor** — `assistants (config)` · `assistant turn subscriber` ·
1295
1457
  `assistant authorship` · `assistant silence` · `assistant seats` ·
1296
- `assistant idle turns` · `drafts` · `ai agents without policy`.
1458
+ `assistant idle turns` · `drafts` · `ai agents without policy` · `message registrations`.
1297
1459
 
1298
1460
  **Test helpers**
1299
1461
 
@@ -1310,10 +1472,54 @@ the count is non-zero.
1310
1472
  | `with_assistant_config(key = nil, **overrides) { … }` | |
1311
1473
  | `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
1312
1474
 
1313
- ### Upgrading to 0.3
1475
+ ### Upgrading to 0.3.2 (including hosts without assistants)
1476
+
1477
+ 0.3.2 adds `support_desk_message_registrations`, an internal receipt per text
1478
+ message. A receipt, written with the ticket clocks and revision in one
1479
+ transaction, distinguishes a replay from an unseen message. Timestamp and UUID
1480
+ order are not evidence of delivery. Every unseen requester message invalidates
1481
+ the turn, notifies the host and runs handoff detection once, even if its timestamp
1482
+ is older; the SLA timestamps themselves never move backwards.
1483
+
1484
+ **This upgrade requires a drained cutover; it is not rolling-safe.** The table
1485
+ is additive, but old processes cannot write receipts.
1486
+
1487
+ 1. Pause support writes and drain old web requests and jobs. Keep the assistant
1488
+ disabled throughout the cutover.
1489
+ 2. Run `rails generate support_desk:upgrade`, inspect the new migration, then
1490
+ `rails db:migrate` while writes remain paused. Fresh installs get the same
1491
+ migration from `support_desk:install`.
1492
+ 3. The migration seeds existing text messages at or before each role's old
1493
+ clock as the historical baseline. Messages beyond those clocks remain
1494
+ discoverable by recovery. **Old data cannot prove whether a message behind
1495
+ the clock lost its callback.** Review suspect historical cases explicitly;
1496
+ the migration does not replay old notifications or reopen history en masse.
1497
+ 4. Start only 0.3.2 processes. Run `SupportDesk.doctor` and
1498
+ `SupportDesk.redispatch_assistant_turns!` before resuming support traffic.
1499
+ Recovery checks closed cases as well: `:reopen_on_reply` reopens them, while
1500
+ `:locked` preserves closure and dispatches no model work. Registration repair
1501
+ also runs on desks with no assistant configured.
1502
+ 5. Resume writes. An assistant rollout still requires its own staging checks
1503
+ and disclosure decision. The serialization guarantee still requires
1504
+ PostgreSQL/MySQL; SQLite does not acquire row locks.
1505
+
1506
+ Do not roll back to an old writer while serving traffic: it would leave missing
1507
+ receipts. If a rollback is necessary, pause and drain first, keep the receipt
1508
+ table, and re-establish the historical baseline before a later upgrade. Removing
1509
+ a receipt deliberately permits processing that message again; it is an internal
1510
+ recovery action, not a normal host API.
1511
+
1512
+ Public assistant `turn:` arguments must be the observed opaque token. Symbols
1513
+ such as `:current` are refused; private outreach and automatic seat-taking do
1514
+ not expose a public bypass. No new host-facing registration API is required.
1515
+
1516
+ ### Historical upgrade from 0.2 to 0.3.0
1517
+
1518
+ The following describes the original assistants migration. When installing the
1519
+ current release, also follow the 0.3.2 cutover above.
1314
1520
 
1315
1521
  ```bash
1316
- rails generate support_desk:upgrade # copies the additive assistants migration
1522
+ rails generate support_desk:upgrade # current releases also copy the receipt migration
1317
1523
  rails db:migrate
1318
1524
  ```
1319
1525
 
@@ -1464,10 +1670,13 @@ supportable), `find_requester` (callable, one argument), `engine mount`,
1464
1670
  (no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
1465
1671
  task), `awaiting` (agrees with the transcript), `references` (unique).
1466
1672
  Assistants (only where one is configured): `assistants (config)`,
1467
- `assistant turn subscriber`, `assistant authorship`, `assistant silence`,
1468
- `assistant seats`, `assistant idle turns`, `drafts`, and `ai agents without
1469
- policy` — which warns about any host class declared `kind: :ai`, since every
1470
- support write by it is refused.
1673
+ `assistant turn subscriber`, `assistant serialization` — which warns when
1674
+ the adapter takes no row locks, because the turn rests on them —
1675
+ `assistant authorship`, `assistant silence`, `assistant seats`, `assistant
1676
+ idle turns`, `drafts`, and `ai agents without policy`, which warns about any
1677
+ host class declared `kind: :ai`, since every support write by it is refused.
1678
+ The seat and draft checks also run once she is no longer configured, since
1679
+ that is exactly when a seat gets stranded.
1471
1680
 
1472
1681
  ## Compatibility
1473
1682
 
@@ -1536,12 +1745,14 @@ SupportDesk.desk(key = :default) # the Desk record, found or created, memo
1536
1745
  SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1537
1746
  SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
1538
1747
  SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
1748
+ SupportDesk.reclaim_assistant_seats! # seats an assistant may no longer sit in
1539
1749
  SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
1540
1750
  SupportDesk.find_topic("billing/invoice")
1541
1751
  SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
1542
1752
  SupportDesk.doctor
1543
1753
  SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
1544
- SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language
1754
+ SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language (a wait: rounded)
1755
+ SupportDesk.humanize_promise(72.hours) # "72 hours" (a promise: in the unit it was made in)
1545
1756
  SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
1546
1757
  SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
1547
1758
  SupportDesk.eligible?(record, condition) # how both macros read their `if:`
@@ -1553,7 +1764,7 @@ SupportDesk::VERSION
1553
1764
 
1554
1765
  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.
1555
1766
 
1556
- `chats` is the kernel this gem is a product on and the two are developed in lockstep, so the Gemfile points at a sibling checkout (`../chats`) whenever this gem needs a `chats` that is not on rubygems yet. It currently requires `chats` ~> 0.3 (the `verified:` messager option).
1767
+ `chats` is the kernel this gem is a product on and the two are developed in lockstep. support_desk requires `chats >= 0.5.1, < 1.0`. Until that is on rubygems, the bundle takes the reviewed chats package from `vendor/cache` (provenance and checksum in `vendor/cache/README.md`), so `bundle exec rake ci` needs no network and no sibling checkout.
1557
1768
 
1558
1769
  ## Contributing
1559
1770
 
@@ -81,10 +81,13 @@ module SupportDesk
81
81
  respond_to?(:chats) ? chats : Chats::Engine.routes.url_helpers
82
82
  end
83
83
 
84
- # Where a case is read and answered: its chats conversation, never a
85
- # second thread UI of our own.
84
+ # Where a case is read and answered: its chats conversation, in the
85
+ # chats inbox (`thread_page = :chats`) or on this engine's ticket page
86
+ # (`:support`). Never a second thread UI of our own: that page renders
87
+ # chats' thread.
86
88
  def conversation_path_for(ticket)
87
89
  return root_path if ticket.conversation.nil?
90
+ return ticket_path(ticket.reference) if SupportDesk.config.support_thread_page?
88
91
 
89
92
  chats_routes.conversation_path(ticket.conversation)
90
93
  end