Product
Ticket Lifecycle, SLA, and Routing Specification
State machine, reply and reopen behaviour, SLA pause and resume, routing decision order, and assignment reasons.
Source: support-system-backend: internal/modules/ticket, internal/modules/sla, internal/modules/routing, internal/app/jobs.go
Ticket Lifecycle, SLA, and Routing Specification
Retrospective. This specification was written from the built system, by reading the code that implements it. It records what the system does, including where behaviour differs from a code comment or from what a reader might assume. It has not been validated with a support team.
This is the ticket-management logic of Support System written as a product specification: what a ticket is, how it changes state, what the SLA clocks do, and how a ticket finds an agent.
1. The ticket
A ticket carries a status, a priority, a type, a channel, a category, a contact, a queue, a team, an assignee, SLA due dates, and an event history.
| Field | Values |
|---|---|
| Status | new, open, pending, on_hold, solved, closed |
| Priority | low, normal, high, urgent |
| Type | question, incident, problem, task, request |
| Channel | api, web, email, phone, whatsapp, sms, webchat, voice, agent |
Defaults on creation: priority normal, type question, channel api.
Every change to a ticket writes a row to its event history and publishes an event that automations, webhooks, and the live browser view consume. Edits use optimistic concurrency: each ticket has a version, and an update sent with an If-Match version that no longer matches is rejected.
2. Status lifecycle
Meaning of each status
| Status | Meaning | SLA clocks |
|---|---|---|
new | Arrived, nobody has replied | Running |
open | Being worked | Running |
pending | Waiting on the customer | Paused |
on_hold | Waiting on a third party | Paused |
solved | The agent states it is fixed; still reversible | Stopped (counts as resolved) |
closed | Final | Stopped |
Allowed transitions
| From | To |
|---|---|
new | open, pending, on_hold, solved |
open | pending, on_hold, solved |
pending | open, on_hold, solved |
on_hold | open, pending, solved |
solved | open, closed |
closed | none |
That is 15 permitted transitions. A request for any other transition is rejected with a conflict error (ticket.invalid_transition). Asking for the status a ticket already has is accepted and does nothing.
What a transition does
- To
solved: stampssolved_atand clears the next-response due date. - To
closed: stampsclosed_at. - From
solvedtoopen: incrementsreopen_count, clearssolved_at, and records areopenedevent rather than an ordinary status change. - Into or out of a paused status: pauses or resumes the SLA clocks (section 4).
- Every transition publishes
ticket.status_changed, plusticket.solved,ticket.closed, orticket.reopenedwhere it applies.
Automatic closing
A background job closes tickets that have stayed solved past a grace period. The period is a workspace setting, auto_close_solved_after_hours, with a default of 72 hours and an allowed range of 1 hour to 90 days. The close is recorded with the reason auto_close.
Closed tickets are immutable
A closed ticket cannot be edited, reassigned, merged, or replied to publicly. Each of those attempts is rejected with a ticket.closed conflict. Internal notes are still accepted. A closed ticket is therefore an archive, not a state that something can quietly leave.
3. Messages and what they change
Agent replies
There are two kinds of message from an agent.
| Kind | Visible to customer | Affects SLA or status |
|---|---|---|
| Public reply | Yes | Yes |
| Internal note | No | No |
A public reply does the following in one transaction:
- Stamps
last_agent_message_atand clears the next-response due date. - If it is the first public reply, stamps
first_responded_atand records afirst_responseevent that notes whether the first-response target had already been breached. - If the ticket has no assignee and the author is a person, assigns the ticket to the author (method
manual). - If the ticket is
new, moves it toopen.
A reply can carry a status in the same request, such as send and set pending, so the message and the status change succeed or fail together. A closed ticket rejects a public reply and tells the agent to create a follow-up ticket.
Mentions in a message are limited to active users of the same workspace and publish a user.mentioned event.
Customer replies
A customer message arrives through a channel and is appended to the ticket it belongs to. What happens next depends on status.
| Ticket status | Result of a customer reply |
|---|---|
new, open | Message appended. |
pending | Message appended and the ticket moves to open. |
on_hold | Message appended; status unchanged. |
solved | Message appended and the ticket reopens. |
closed | A new follow-up ticket is created and linked. |
Notes on this table:
- The code comment above the lifecycle says a customer reply reopens solved tickets. The code also reopens pending tickets, which is the behaviour listed here. On-hold tickets do not reopen on a customer reply, because the ticket is waiting on a third party rather than on the customer.
- A follow-up ticket takes the subject
Follow-up:plus the original subject, copies the priority and channel, uses typequestion, and is linked to the closed ticket with the link kindfollow_up. - When a customer replies after the agent has already responded, a next-response SLA clock starts if the policy defines a next-response target and none is already running.
Merge, links, and bulk actions
- Merge moves messages and call logs from a source ticket to a target ticket, combines their tags, closes the source with a pointer to the target, links the two as
duplicate, and adds a system note to the target. Both tickets are locked in a fixed order so two merges at once cannot deadlock. A merge involving a closed ticket is rejected, and a ticket cannot be merged into itself. - Links relate tickets without merging them.
- Bulk actions apply the same operations to many tickets and report a result per ticket.
4. SLA
Policy selection
SLA policies are ordered. When a ticket is created, the system takes the first active policy, by position, whose condition matches the ticket. A condition is an expression over ticket fields. A policy that has no target for the ticket's priority is skipped, and the search continues. If no policy matches, the ticket has no SLA.
Targets
A policy defines, for each priority, up to three targets in business minutes:
| Target | Starts | Satisfied by |
|---|---|---|
| First response | Ticket creation | First public agent reply |
| Next response | A customer reply after the first agent reply | Next public agent reply |
| Resolution | Ticket creation | Moving to solved |
Business minutes are counted against a business-hours calendar with a timezone, weekly schedule, and holidays. A policy may use its own calendar. A calendar with no weekly schedule and no holidays is open around the clock, and one with holidays but no schedule is open all day except on the holidays.
Pause and resume
- Moving to
pendingoron_holdrecordssla_paused_at. - Moving to any other status resumes the clocks. The business time between the pause and the resume is computed on the policy's calendar, and each active due date (first response if not yet met, resolution, next response) is moved forward by that amount.
- Changing a ticket's priority recomputes its SLA plan.
Warning and breach
A scanner runs every 30 seconds, on one instance at a time, and looks at tickets that are not paused and not solved or closed.
| Event | Condition |
|---|---|
| Warning | First-response or resolution due within 15 minutes, and not already warned |
| First-response breach | No first reply and the due time has passed |
| Resolution breach | The resolution due time has passed |
| Next-response breach | The next-response due time has passed |
Each hit writes a ticket event and publishes ticket.sla_warning or ticket.sla_breached. Automations can use those events for escalation, and webhooks and the live view receive them.
5. Routing
Routing assigns a ticket in a queue to an agent. It runs when a ticket is created, requeued, or reopened, when an agent comes online or changes their profile (queued tickets are then drained), and on a periodic sweep. Routing for one queue is serialised with a database advisory lock, so two routers cannot assign the same ticket twice, while different queues route in parallel.
Queue strategy
Each queue has a strategy: round_robin, least_loaded, or manual. A ticket in a manual queue is held with the reason manual_queue, and a person assigns it.
Decision order
For a ticket that has no assignee, is not solved or closed, and belongs to a queue:
- Manual queue. Hold the ticket. Stop.
- Sticky routing, if enabled for the queue and the ticket has a contact. See section 5.1.
- Agent selection. See section 5.2.
- Nobody qualifies. The ticket stays queued with the reason recorded.
5.1 Sticky routing
The intent is that a returning customer reaches the agent who helped them before.
- Find the agent who most recently sent a public reply on any of this customer's tickets inside the lookback window (default 30 days). The customer is recognised by contact record, phone number, or email, as configured.
- Check that agent against the eligibility rules, each of which can be switched on or off in the policy:
| Rule | Default |
|---|---|
| Agent is active | always |
| Agent is receiving tickets | always |
| Agent is online | on |
| Agent is in the queue's team | always |
| Agent is in shift (agents with no schedule always qualify) | on |
| Agent has the ticket's skill (generalists always qualify) | on |
| Agent speaks the customer's language | on |
| Agent is under capacity | off |
- If eligible, assign to that agent with the reason
sticky, orreopen_stickywhen the previous work was on this same ticket and the policy allows returning reopened tickets to their last agent. - If not, record why it was skipped (for example
previous_agent_out_of_shift) and fall through to agent selection.
Capacity is off by default on purpose: continuity wins over evenness unless a workspace decides otherwise.
5.2 Agent selection
Selection is a pure function over the candidate agents, which is why it is straightforward to unit test.
- Candidates are agents who are active, receiving tickets, online, in the queue's team, and able to handle the customer's language.
- Qualified: if the ticket has a skill (the category key), drop agents who have a skills list but not that skill. Agents with no skills listed are generalists and qualify.
- Capacity: drop agents whose current load is at or over their capacity.
- Shift: prefer agents who are in shift, or who have no schedule. If nobody is in shift, fall back to out-of-shift agents only when the policy allows it. Otherwise the ticket is queued with the reason
no_agent_in_shift. - Order by strategy and take the first:
| Strategy | Order |
|---|---|
least_loaded | Lowest utilisation (load divided by capacity), then highest skill level, then least recently assigned |
round_robin | Least recently assigned (never assigned first), then highest skill level |
Load counts the tickets assigned to an agent in the statuses the policy lists. The default is new and open, so a pending ticket does not use up an agent's capacity unless a workspace says it should.
Reasons a ticket stays queued
| Reason | Meaning |
|---|---|
manual_queue | The queue is manual |
no_online_agent | Nobody is online and eligible |
no_qualified_agent | Agents are online but none has the skill |
all_agents_at_capacity | Qualified agents exist but all are full |
no_agent_in_shift | Only out-of-shift agents qualify and fallback is off |
The reason is stored on the ticket and shown in the agent's inbox.
5.3 Assignment reasons
Every assignment stores a method (auto or manual) and a reason, so the answer to why this ticket went to this agent is on the ticket.
| Reason | Meaning |
|---|---|
auto_least_loaded | Chosen by the least-loaded strategy |
auto_round_robin | Chosen by the round-robin strategy |
sticky | Returned to the customer's previous agent |
reopen_sticky | A reopened ticket returned to its own last agent |
rebalanced | Moved by rebalancing |
6. Keeping the queue healthy
Draining and overflow
Queued tickets are retried in order of priority (urgent first), then by how long they have waited. A queue can name an overflow queue and a delay in seconds (between 30 seconds and 7 days). A ticket still queued and unassigned after that delay moves to the overflow queue. A queue cannot overflow into itself.
Rebalancing
Rebalancing evens out work when one agent is overloaded and another has room. It only touches tickets that:
- were assigned automatically,
- have never been opened or answered,
- have waited at least the minimum wait (default 15 minutes), and
- are not sticky assignments, unless the policy says otherwise.
It repeatedly takes the oldest movable ticket from the busiest agent and gives it to the least busy agent who is qualified and has room, while the gap in load is larger than the stop gap (default 1). It makes at most a set number of moves per run (default 50). Each move is a conditional update, so if an agent starts working a ticket in the meantime, that agent keeps it. Rebalancing also runs when an agent comes online, if the workspace policy has that option on (it is on by default once rebalancing is enabled). That trigger reads the workspace policy rather than a queue's own override.
Releasing tickets from unavailable agents
Tickets return to the queue when their agent is unavailable, according to the release policy.
| Setting | Default |
|---|---|
| Agent offline | Release immediately (options: after N minutes, never) |
| Agent away | Release after 10 minutes (0 means never) |
| Agent busy | Do not release (if enabled, after 30 minutes) |
| Only untouched tickets | On |
Deactivating a user releases all of their open tickets. A ticket released this way is recorded in its history with the reason.
7. Routing policy layers
Routing behaviour comes from three layers, applied in order.
- Built-in defaults.
- Workspace override.
- Queue override.
Overrides are partial: a queue can change only the lookback window and inherit everything else. When an override is saved, unknown keys are rejected and values are range-checked (for example the lookback window must be between 1 and 365 days, and at least one way of recognising a returning customer must be selected). If a stored queue policy ever fails validation, the system falls back to the workspace policy, and then to the defaults, rather than failing to route.
The default policy is deliberately conservative: sticky routing off, rebalancing off. A new workspace behaves like a plain queue until a supervisor turns something on.
8. Worked example
A customer, Rita, emails about a refund on Monday at 17:05.
- A ticket is created in the
Generalqueue, statusnew, prioritynormal. The SLA policy that matches gives a first-response target in business minutes, so the due date lands on the next business morning if the office has closed. - Routing runs. Sticky routing is on, but Rita has no previous ticket in 30 days, so it falls through. Selection finds no agent online, so the ticket is queued with the reason
no_online_agent. The inbox shows the reason on the ticket. - At 09:00 an agent comes online. Draining assigns the ticket with the reason
auto_least_loaded. - The agent replies and sets the ticket to
pending. First response is stamped. All SLA clocks pause. - Rita answers two days later. The ticket moves to
openand the paused clocks resume, with each due date moved forward by the business time spent paused. A next-response clock starts. - The agent solves the ticket. After 72 hours with no reply, the auto-close job closes it.
- A week later Rita writes again. Because the ticket is closed, a new ticket
Follow-up: refundis created and linked to the old one. Sticky routing, now with a recent ticket to look at, offers the new ticket to the same agent if that agent is online, in shift, skilled, and speaks Rita's language.
9. Known limits of this specification
- The behaviour of the SLA scanner and the routing sweep is described from the code. The only automated tests cover the state machine, the SLA calendar, agent selection, the routing policy, and rebalancing planning. The database-backed flows (reopen, merge, follow-up creation, auto-close) are exercised by the end-to-end smoke test rather than by isolated tests.
- Which ticket statuses count toward an agent's load is a workspace setting. This document describes the default.
- The expression language used for SLA conditions and automations is not specified here.