Tickets
A ticket is a unit of support work on a thread. It carries the assignee, the priority, the closing attribution and the sentiment scores; the thread carries the conversation. One thread accumulates many tickets over its life, but never more than one open at a time.
The one-open-ticket invariant
Enforced by a partial unique index in raw migration SQL, which Prisma cannot express:
CREATE UNIQUE INDEX "inbox_ticket_one_open_per_thread"
ON "inbox_ticket" ("threadId") WHERE status = 'open';
Nothing in the service reads first and then writes. TicketsService.open attempts the insert and lets
the database refuse it; a P2002 is translated into conflict (409), so no Prisma error ever reaches a
client. Anything other than that P2002 propagates untouched — a unique violation on legacyTicketId is
a migration bug, not something a client can resolve by closing a ticket.
The reason it works this way is history. axis-api enforced the same rule in application code and it raced: two concurrent opens each read "none open" and each inserted. The November 2025 fix needed a row lock, an index and a shared assign path, and still left 93 bad rows behind.
reopen is subject to the same index, so a thread whose work resumed on a newer ticket cannot have an
older one reopened underneath it.
autoOpenForThread swallows the conflict
The ingest hook (step 16 of How a message arrives) opens a ticket on the
first inbound of a thread, for every network including ai — the AI close rate is unmeasurable unless
a ticket exists to be closed. A second inbound finds an open ticket, the index refuses the insert, and
the opener treats that conflict as success and returns. Re-delivery never opens two.
The ticket number and the assignee are resolved independently and best-effort: either failing must not block the other or the open itself. A customer's stored message must never be lost to a numbering hiccup, so the ticket opens unnumbered or unassigned and a later action fills the gap.
Ticket numbers
Tenant.ticketPrefix (globally @unique) plus an atomically bumped Tenant.ticketSeq, rendered as
<prefix><counter> zero-padded to at least four digits — KL0001, and KL10000 once the counter
passes 9999.
The prefix is the workspace name's initials (first code point of each whitespace-split word,
uppercased), claimed through a disambiguating path so two workspaces whose names produce the same
initials get TKT, TKT2, TKT3 rather than colliding. A nameless workspace falls back to TKT.
Numbers are monotonic, not gapless. autoOpenForThread consumes a number before attempting the
insert, so a swallowed conflict wastes one — by design. Migrated tickets keep their imported axis-api
numbers; a second partial unique index covers native rows only
(where ticketNumber is not null and legacyTicketId is null), because imported numbers predate
global-prefix uniqueness.
Assignment
selectAssignee picks the least-loaded candidate: among the tenant's members that Accounts reports
as active, assignable and available, the one with the fewest currently-open assigned tickets, ties
broken by lowest id. With no assignable candidate it falls back to the first active admin. With nobody
at all it returns undefined and the ticket opens unassigned, which is a valid state.
Gotcha: a machine key cannot assign
validateAssignee refuses outright when the caller has no user:
422 validation_failed
"Assignment requires a user session; a service key cannot validate an assignee."
A sk_ service key carries no user and therefore no group membership, so it has nothing to validate
against. "No caller identity" must never mean "no check" — an unvalidated assignment writes an Accounts
UUID that Inbox can never resolve and that nothing downstream can detect.
When there is a session, assertMember short-circuits self-assignment (the caller's own session
already proves membership — and that is the common case, an agent picking up a ticket) and otherwise
asks Accounts. A non-member produces a deliberately generic validation_failed (422), not
not_found, so the endpoint cannot be used to probe which Accounts user ids exist.
This applies to assign, reassign, mass-reassign and open with assignedUserId. close,
reopen and mass-close need no session.
Closing
close(tenantId, ticketId, body, closedByKind) where closedByKind is ai | human | system,
defaulting to human because the HTTP route is a person clicking resolve. The AI auto-close and any
auto-resolver pass their own kind, and GET /v1/tickets/statistics groups on exactly this column.
Closing is idempotent: closing an already-closed ticket returns the same DTO rather than erroring, so a
double-click on resolve is not a failure. It also flips thread.status to closed (best-effort), so
the Open and Closed tabs agree — consumers filter on the thread's status, and closing only the ticket
used to make a resolved conversation vanish from Open without appearing under Closed. The one-open
index guarantees no other open ticket remains on that thread, so this cannot hide live work. reopen
mirrors it.
AI resume on reopen
When autoOpenForThread actually opens a ticket (rather than hitting the conflict), the thread had no
open ticket — either it is brand new or a closed conversation just reopened. resumeAiOnReopen then
restores handler: 'ai' and clears takenOver/takenOverAt, but only when both hold:
- the thread's current handler is
'human'(a thread already on AI, or never taken over, is untouched) - the connection is AI-designated —
InboxAccountConfig.defaultHandler === 'ai'ornetwork === 'ai'
Without it, a thread a human took over and then closed stays at handler: 'human' forever, because
close and reopen touch only ticket columns — and the next inbound would be met with silence. A genuinely
human inbox is never hijacked.
Routes
All eleven are @RequireScopes('inbox'); every write is @DataWrite() and needs an Idempotency-Key.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/tickets |
Filters status, assignedUserId, unassigned, priority, threadId, limit, cursor |
| GET | /v1/tickets/statistics |
Includes closesByKind |
| GET | /v1/tickets/mine |
The caller's own open queue; 422 for a machine key |
| GET | /v1/tickets/:ticketId |
|
| POST | /v1/tickets |
201. 409 conflict if the thread already has an open ticket |
| POST | /v1/tickets/:ticketId/close |
200. Body { closingComment? } |
| POST | /v1/tickets/:ticketId/reopen |
200. Same index applies |
| POST | /v1/tickets/:ticketId/assign |
200. Body { assignedUserId } |
| POST | /v1/tickets/:ticketId/reassign |
200. Body { assignedUserId, reassignmentComment? } |
| POST | /v1/tickets/mass-reassign |
200. Assignee validated once, tenancy per ticket |
| POST | /v1/tickets/mass-close |
200. Reuses single close per id |
Both bulk routes check ownership of every id up front, so an unowned id fails the whole batch rather
than acting on a prefix. mass-close counts already-closed ids as no-ops, not errors.