Releases: rameerez/support_desk
Release list
0.3.2
Fixed
- Persist message-registration receipts atomically with clocks and turn revisions.
A new message with an earlier timestamp or smaller UUID is no longer dropped
as a replay. It invalidates the turn and triggers requester notifications and
handoff detection once, without rewinding clocks. This also fixes human-only
desks; no assistant configuration is needed to encounter the old defect. - Recovery discovers unregistered replies on closed cases and honors the desk's
reopen/locked policy. Repair also runs when the assistant is disabled; only
actual assistant dispatch requires one to be configured. - Public assistant assignment, release and reply refuse
turn: :current.
Internal outreach and seat-taking use private paths and actual tokens. - Default Chats signatures render assistant messages using per-message
disclosure snapshots, including old 0.3.0 messages with provenance but no
assistant-row snapshot. Rename, mode change and flag-off do not erase old
disclosure. Human replies and approved drafts keep human signatures, and host
signature overrides remain authoritative. Transcript names are snapshotted
for new messages; older ones use their stored display name. - Correct the README signature override to preserve the human fallback.
Upgrade
Run rails generate support_desk:upgrade and migrate with support writes paused
and all old web/jobs drained. The new receipt table follows the existing ticket
and message ID types and seeds historical messages up to the old role clocks.
Old clocks cannot reveal lost callbacks behind that baseline: review suspect
historical cases before resuming. Only 0.3.2 processes may write after cutover.
See README for recovery, rollback limitations and the unchanged SQLite caveat.
No gem publication or deployment is performed by this change.
0.3.1 — Assistants hardening
Hardening the assistant, after an adversarial review of 0.3.0. Nine defects in the concurrency, recovery and kill-switch guarantees the release advertised — every one now a regression test that failed on 0.3.0. Nothing changes what an assistant may do; it changes what can happen to a case while she is doing it.
- A customer's next question can no longer be answered around (R1). Every speaking path locks the ticket, then the conversation row chats updates inside every message insert, before reconciling — so an in-flight message blocks the answer until it commits and makes the turn stale. Holds on PostgreSQL and MySQL; on SQLite it is documented and
doctorwarns. - Seats are decided under the lock (R2);
assign!/release!take aturn:whenby:is an assistant — the one API change. - A lost registration is repairable (R3): the fold commits on its own, and
redispatch_assistant_turns!looks for unregistered messages before it looks at clocks. - Equal-timestamp messages no longer deadlock the turn (R4): the watermark is
(created_at, id)in chats' transcript order, one rule in Ruby and SQL. - The silent sweep rechecks before it escalates (R5).
- Stranded seats come back (R6):
SupportDesk.reclaim_assistant_seats!/rake support_desk:reclaim_assistant_seatsread the seats that exist, with no timeout and no clock; doctor seat checks run whenever assistant rows exist. - Running out of turns publishes
:ticket_escalated(R7). - Disclosure on old messages stops moving (R8): name and mode are snapshotted onto the assistant row.
- The kill switch is read after waiting for the lock (R9).
Hosts with no assistant are untouched. Gem SHA-256: 0b7bd2310212553a911aa0643ef872aff63aae901a13308b689f709e4dd5b886
0.3.0 — Assistants
A machine can answer, and a person still owns every word it sends.
An assistant is an agent with a policy: a seat, a name, a turn budget and a level (off · observe · draft · reply · resolve) that says what she may produce on a case. At the default level that is a proposal a human reads and sends. Nothing turns itself on — without config.assistant the gem behaves exactly as 0.2 did.
Highlights
- The gem owns the guardrails, the host owns the model. No LLM dependency, no prompt, no retrieval, no job loop. A ≤30-line job subscribes to
:assistant_turn, readsticket.brief, calls any model, and hands the answer toticket.respond!(text, by: rose, turn:). Policy decides what that becomes. - The turn.
assistant_revisionmoves on every message and every transition; every assistant action requires and consumesassistant_turn, so a late, retried or redelivered job raisesStaleTurnand writes nothing. No idempotency keys, claims or leases needed. - Human in the loop.
SupportDesk::Draft— one pending proposal per case; Enviar / Editar / Descartar in the console; a sent draft is the human's message with the machine's provenance in metadata. A stale approval is refused, never sent. - Two exits, always.
escalate!(assistant → humans) andrequest_human!(the customer's door,POST /tickets/:id/request_human). Humans outrank assistants under everyreply_policy. - Disclosure is your explicit choice.
disclosureis required with no default::signature_and_notice,:signature,:noticeor:none. Exports sayfrom: "assistant"in every mode. - A silent assistant never hides a case.
rake support_desk:release_silent_assistantsreleases her seat and asks for a person;redispatch_assistant_turnsretries dropped turns; eight new doctor checks. - Context as data.
ticket.transcript(four roles) andticket.brief(versioned facts,may/may_not, internal notes opt-in);Requester#support_context. - DX.
rails g support_desk:assistant Rose --disclosure MODE,SupportDesk::TestHelpersadditions, a full README section and reference.
Behaviour changes regardless of configuration
A host acts_as_support_agent kind: :ai model is now refused for every support write; console picker values are actor keys; Queue::TABS gains :needs_human (hidden unless relevant); every registration/transition writes assistant_revision. See the CHANGELOG for the upgrade steps — additive and rolling-safe.
Gem SHA-256: 885d2d97899879c94df47a63ffec810d5c820a229e814d201667dd7e64f75c19
What's Changed
- System test: wait for the 422 navigation before touching the form by @rameerez in #4
- README: the whole public surface, as a reference by @rameerez in #5
- Assistants: AI agents as a first-class use case (0.3.0) by @rameerez in #6
Full Changelog: v0.2.0...v0.3.0
v0.2.0
support_desk 0.2.0 — the desk can write first
lucia.open_support_conversation_with!(alice, "We saw your refund bounced", about: order)- New verb, same seam.
agent.open_support_conversation_with!(requester, message, about:, topic:)overTicket.open!(…, by: agent);opened_byis a record likeclosed_by, withopened_by_requester?/opened_by_support?and scopes. - Atomic. The case, the opener's seat (
reason: opened, no announcement), the opening line and the first message are one savepoint — a refused message leaves nothing behind.reply!gets the same guarantee. - The limits are on asking, not on being asked.
open_rate_limit/max_open_ticketscount only requester-opened cases. config.opening_line/config.opening_line_from_support— the system line a thread opens with, posted inside the transaction and ordered before the first message by construction.config.find_requesterfor the console's "Write to someone".has_support_tickets if:— who may ask and be written to; eligibility is checked at every write.- Console:
new+open_conversation, GlobalID allow-lists, reuse authorization under the row lock, 303/422 with the draft preserved; the door in the mounted and generated consoles. - Schema: one additive migration (
rails g support_desk:upgrade),rake support_desk:backfill_opened_by, doctor checks. - Read the CHANGELOG's "Upgrading from 0.1": this is a drained cutover, not a rolling deploy — 0.1 processes reject the new
openedassignment reason. - Verified on Rails 7.2 / 8.0 / 8.1 (SQLite) and PostgreSQL; rubocop and brakeman clean.
What's Changed
Full Changelog: v0.1.3...v0.2.0
v0.1.3 — release integrity and correctness fixes
Correctness fixes from an integration review against a real host, plus the dependency bump that makes signing work without turning staff into messagers.
Fixed
- Opening a case and its first message is atomic. A rejected upload no longer leaves a case with nothing in it; the validation error renders with the draft preserved.
- Reopening a historical case keeps both conversations when a newer case already exists, instead of collapsing them. New submissions still reuse the open case.
- Assignment authorizes its actor, and assignment, hand-off, release and the reply policy all check current state under the row lock — so two agents racing on the same case cannot both win.
- Delayed message registration reads both clocks to decide whose turn it is, and can no longer reopen a case that was closed after the message was sent.
- Reading a console transcript marks the desk's read horizon through the last message actually displayed.
- Topics resolve within their own ticket's desk, and wizard routing and response promises honour non-default desks.
- The requester upload form is multipart, and its send budget is shared with chats rather than being a second, separate limit.
Requires chats >= 0.3.2
Authors there need no messaging capabilities of their own, so a staff member signs a desk's reply without being given acts_as_messager. The dependency is ~> 0.3, >= 0.3.2.
Upgrading
No migration, no configuration change. If you are on 0.1.1, upgrade regardless: that package was built from the wrong tree and raises on every requester screen.
Verified
| Tests | 661 runs, 3004 assertions, 0 failures |
| Databases | SQLite, PostgreSQL |
| Rails | 7.2, 8.0, 8.1 |
| Lint / security | rubocop clean, brakeman 0 warnings |
What's Changed
New Contributors
Full Changelog: v0.1.2...v0.1.3
v0.1.2
0.1.1 was packaged from the wrong tree. This is the release it should have been.
The gem published to rubygems as 0.1.1 does not declare verified: true on SupportDesk::Desk, so no desk is badged — while its release notes said that was the entire point of the release.
The repository was correct the whole time. The tag, main, and every test all carried the change; only the package did not. That is the worst shape this mistake can take: nothing in git looks wrong, and the suite passes, because the suite runs against the repository rather than against what was shipped.
What changed
-
SupportDesk::Deskdeclaresverified: true, soSupportDesk::Desk.chat_verified?is true and every desk carries the official-account mark in the inbox row, the grouped row and the thread header. -
A guard test asks the loaded class, not the source file:
assert SupportDesk::Desk.chat_verified?, "SupportDesk::Desk is not verified — if the repo source says it is, " \ "the loaded gem was built from a different tree"
Run inside a packaged gem, that reads the packaged file. A build from a stale tree now fails instead of shipping quietly.
If you installed 0.1.1
Upgrade. Nothing else is different, there is no migration and no API change: you get the badge that 0.1.1 promised.
Verified
| Tests | 647 runs, 2945 assertions, 0 failures |
| Databases | SQLite, PostgreSQL |
| Rails | 7.2, 8.0, 8.1 |
| Lint / security | rubocop clean, brakeman 0 warnings |
| The package itself | unpacked and checked: carries verified: true |
Requires chats ~> 0.3.
Full Changelog: v0.1.1...v0.1.2
v0.1.1
The support desk is an official account.
Every desk now carries the mark that tells a customer the account is really you — in their inbox row, in the grouped row that folds their cases together, and at the top of the thread.
# nothing to write: SupportDesk::Desk declares it
acts_as_messager verified: true, notifications: false, blockable: false, inbox: :groupedIt is real behaviour from chats 0.3.1, not a picture: a scalloped rosette with a tick knocked out, announced to screen readers as "Official account", its colour the --chats-verified custom property, its glyph replaceable with config.verified_badge.
Why it matters for a support desk specifically: the one conversation in someone's inbox that will ask them to confirm a refund, a card, or an address is the one an impostor most wants to sit next to. A mark that says this is us is worth more here than on any other kind of account.
Also in this release
The README leads with the product. Nine screens of a support desk running on a made-up delivery app — the category a DoorDash, an Uber Eats or a Grab occupies, because everyone has needed support from one. Grouped inbox, topic picker, order picker, compose with context, signed reply, agent queue, the case, internal notes, hand-off.
Upgrading
Two things to know:
- Requires
chats ~> 0.3. 0.2.x has noverified:keyword, so a host resolving an older chats would hitArgumentErrorat class definition, beforeSupportDesk.doctorcould explain anything. The dependency is tightened so bundler says it first. - Nothing else changed. No migration, no configuration, no API. Upgrade and your desk is badged.
Verified
| Tests | 646 runs, 2944 assertions, 0 failures |
| Databases | SQLite, PostgreSQL |
| Rails | 7.2, 8.0, 8.1 |
| Lint / security | rubocop clean, brakeman 0 warnings |
Run against the published chats 0.3.1, not a local checkout.
Full detail in CHANGELOG.md.
Full Changelog: v0.1.0...v0.1.1
v0.1.0
Customer support for any Rails app: tickets that are real conversations.
Somebody asks for help about something in your app. A desk answers. Your staff sign their replies. Your team works a queue. All of it on threads your users already understand, because it is the same messaging they use for everything else.
support_desk is a product gem on the chats kernel: chats owns the transcript, realtime, attachments, read state and moderation; this gem owns everything a case needs on top. The same shape as usage_credits on wallets.
class User < ApplicationRecord
acts_as_messager # chats
has_support_tickets # the person asking
acts_as_support_agent if: :admin? # the person answering
end
class Order < ApplicationRecord
supportable topic: :order # "I need help with this order"
end
ticket = alice.ask_support!("It never arrived", about: order)
ticket.assign!(to: lucia)
ticket.reply!("Looking into it now", by: lucia)
ticket.close!(by: lucia)What ships in 0.1.0, and what each is for
Cases that are conversations
- A ticket is one chats conversation, with the ticket as its subject. One transcript, one read horizon, one realtime story, one moderation contract.
- Why: assignment, response clocks and audit are all per case. A single eternal thread per customer cannot answer "who owns this, and how long have they been waiting".
A desk that is not a person
- The desk sends every reply; humans and bots author them. Your users see one counterpart, "Support", with a signature under each answer.
- Why: staff come and go and hand cases to each other. Handoffs change who writes, never who the thread is with, and nobody's personal profile is exposed to customers.
An inbox that stays usable
- Every case with the desk collapses into one row in the user's messages, with an aggregate unread count. Tapping it opens a support-only list.
- Why: a customer with a dozen past cases should not have their real conversations pushed off the screen. This is a chats primitive, so it also works for shops, organizations or bots.
Topics, as a tree you define in code
topic :order, about: Orderwith pickers, prefill, visibility rules, per-topic routing and priority, and a required free-form exit.- Why: a topic decides which of the user's records they can attach, what the wizard asks, and where the case lands. That is behaviour, so it belongs in code, reviewed and versioned.
Assignment as a history
- Take, assign, hand off with a note, release, plus the full record of who held a case, when, and why they stopped.
- Why: "time per agent", shift handovers and drop-in cover are unanswerable from a single assignee column.
A console you bring your own UI to
- Three layers, stop at whichever you like: query objects and presenters that work in any UI; a controller concern plus a routing concern for any admin framework; or
rails g support_desk:console madmin, which writes a full console into your app that you own and can edit. - A mountable turnkey console ships too, for apps with no admin framework.
Requester screens, included
- A mounted engine with the list, a three-step wizard, entry points you drop anywhere with
link_to_support about: @order, and ejectable views. - Hotwire Native path rules included, so the flow behaves on mobile.
Events, not opinions
SupportDesk.on(:ticket_opened),:requester_replied,:ticket_transitionedand nine more. Multi-subscriber, error-isolated, emitted after commit.- Why: the gem never sends your email, push or Slack. It tells you what happened and stays out of the way. A subscriber that raises is reported and never rolls back a ticket.
Things it refuses to get wrong
- Authorization both ways. A requester cannot close, refile, reassign or reopen someone else's case, and cannot reach a topic hidden from them by typed path, deep link or a record's own topic. An agent cannot read a desk they are not allowed to see, and the refusal does not leak whether a case exists.
- Concurrency. "One open case per thing" is enforced by a partial unique index on PostgreSQL and SQLite, so two simultaneous requests produce one case, not two. MySQL has no partial indexes, so there the model plus
SupportDesk.doctorhold the line, and the migration says so. - Privacy. Internal notes are events, never messages: they never reach the customer's thread and never appear in a data export. Notification titles carry no customer name by default.
- Idempotency. A redelivered message event never rewinds a clock or reopens a case twice.
- Replayable state.
SupportDesk.doctorchecks the invariants the database cannot, and exits non-zero, so a mis-wired desk fails your build rather than a customer's support screen.
Verified
| Tests | 638 runs, 2904 assertions, 0 failures, 0 skips |
| Databases | SQLite, PostgreSQL |
| Rails | 7.2, 8.0, 8.1 |
| Lint / security | rubocop clean, brakeman 0 warnings |
Built against the released chats 0.2.0, not a local checkout.
Full detail in CHANGELOG.md and the README.
Full Changelog: https://github.com/rameerez/support_desk/commits/v0.1.0