Skip to content
Support System

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.

FieldValues
Statusnew, open, pending, on_hold, solved, closed
Prioritylow, normal, high, urgent
Typequestion, incident, problem, task, request
Channelapi, 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

StatusMeaningSLA clocks
newArrived, nobody has repliedRunning
openBeing workedRunning
pendingWaiting on the customerPaused
on_holdWaiting on a third partyPaused
solvedThe agent states it is fixed; still reversibleStopped (counts as resolved)
closedFinalStopped

Allowed transitions

FromTo
newopen, pending, on_hold, solved
openpending, on_hold, solved
pendingopen, on_hold, solved
on_holdopen, pending, solved
solvedopen, closed
closednone

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: stamps solved_at and clears the next-response due date.
  • To closed: stamps closed_at.
  • From solved to open: increments reopen_count, clears solved_at, and records a reopened event 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, plus ticket.solved, ticket.closed, or ticket.reopened where 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.

KindVisible to customerAffects SLA or status
Public replyYesYes
Internal noteNoNo

A public reply does the following in one transaction:

  1. Stamps last_agent_message_at and clears the next-response due date.
  2. If it is the first public reply, stamps first_responded_at and records a first_response event that notes whether the first-response target had already been breached.
  3. If the ticket has no assignee and the author is a person, assigns the ticket to the author (method manual).
  4. If the ticket is new, moves it to open.

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 statusResult of a customer reply
new, openMessage appended.
pendingMessage appended and the ticket moves to open.
on_holdMessage appended; status unchanged.
solvedMessage appended and the ticket reopens.
closedA 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 type question, and is linked to the closed ticket with the link kind follow_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:

TargetStartsSatisfied by
First responseTicket creationFirst public agent reply
Next responseA customer reply after the first agent replyNext public agent reply
ResolutionTicket creationMoving 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 pending or on_hold records sla_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.

EventCondition
WarningFirst-response or resolution due within 15 minutes, and not already warned
First-response breachNo first reply and the due time has passed
Resolution breachThe resolution due time has passed
Next-response breachThe 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:

  1. Manual queue. Hold the ticket. Stop.
  2. Sticky routing, if enabled for the queue and the ticket has a contact. See section 5.1.
  3. Agent selection. See section 5.2.
  4. 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.

  1. 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.
  2. Check that agent against the eligibility rules, each of which can be switched on or off in the policy:
RuleDefault
Agent is activealways
Agent is receiving ticketsalways
Agent is onlineon
Agent is in the queue's teamalways
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 languageon
Agent is under capacityoff
  1. If eligible, assign to that agent with the reason sticky, or reopen_sticky when the previous work was on this same ticket and the policy allows returning reopened tickets to their last agent.
  2. 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.

  1. Candidates are agents who are active, receiving tickets, online, in the queue's team, and able to handle the customer's language.
  2. 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.
  3. Capacity: drop agents whose current load is at or over their capacity.
  4. 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.
  5. Order by strategy and take the first:
StrategyOrder
least_loadedLowest utilisation (load divided by capacity), then highest skill level, then least recently assigned
round_robinLeast 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

ReasonMeaning
manual_queueThe queue is manual
no_online_agentNobody is online and eligible
no_qualified_agentAgents are online but none has the skill
all_agents_at_capacityQualified agents exist but all are full
no_agent_in_shiftOnly 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.

ReasonMeaning
auto_least_loadedChosen by the least-loaded strategy
auto_round_robinChosen by the round-robin strategy
stickyReturned to the customer's previous agent
reopen_stickyA reopened ticket returned to its own last agent
rebalancedMoved 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.

SettingDefault
Agent offlineRelease immediately (options: after N minutes, never)
Agent awayRelease after 10 minutes (0 means never)
Agent busyDo not release (if enabled, after 30 minutes)
Only untouched ticketsOn

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.

  1. Built-in defaults.
  2. Workspace override.
  3. 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.

  1. A ticket is created in the General queue, status new, priority normal. 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.
  2. 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.
  3. At 09:00 an agent comes online. Draining assigns the ticket with the reason auto_least_loaded.
  4. The agent replies and sets the ticket to pending. First response is stamped. All SLA clocks pause.
  5. Rita answers two days later. The ticket moves to open and the paused clocks resume, with each due date moved forward by the business time spent paused. A next-response clock starts.
  6. The agent solves the ticket. After 72 hours with no reply, the auto-close job closes it.
  7. A week later Rita writes again. Because the ticket is closed, a new ticket Follow-up: refund is 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.