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:

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.

Related