Outpost is meant to answer exactly one message per ticket. That invariant is enforced
per ticket (see the guard in packages/outpost/queue/src/handlers/ai-response.ts), so
it holds only as long as one conversation maps to one ticket. None of the four inbound
doors guarantee that: every one of them will happily create a second ticket for a
message it has already seen, and the second ticket gets its own AI answer. From the
reporter's side that is indistinguishable from the bot answering twice.
Found by the review round on the one-response-per-ticket change; deferred because the
fix needs a schema migration and touches all four doors, which is a different blast
radius than that change had.
Sites
packages/outpost/shared/src/platforms/inbound.ts:158 — handleNewTicket does no
find-first on (source, sourceId), and there is no unique constraint backing it
(schema.prisma has an index only). A redelivered thread-start creates a second
ticket and a second answer.
apps/web/src/app/api/webhooks/postmark/route.ts:102 — no MessageID idempotency.
Postmark retries on any non-2xx, and the handler returns 500 if createJob throws
after ticket.create succeeds, so the retry duplicates both.
apps/github-app/src/webhooks/issue-comment.ts:53 — no comment.id idempotency;
redelivery duplicates messages.
apps/slack-bot/src/events/message.ts:41 — no thread-start dedupe.
apps/discord-bot/src/events/thread-create.ts:75 — a duplicate ThreadCreate
duplicates ticket and answer.
Also fixed by the same constraint
packages/outpost/queue/src/handlers/ai-response.ts:97 — the one-response guard is
check-then-write. AI_RESPONSE runs at concurrency 4 (apps/worker/src/index.ts:63),
so two jobs for the same ticket can both read "no BOT row yet" and both answer. A
transaction around the check does not close this across connections; a uniqueness
constraint does. That is why it belongs here rather than in the guard's own PR.
Suggested shape
- Migration: unique on
Ticket(source, sourceId) where sourceId is non-null, plus
whatever partial-unique expresses "at most one AI-generated BOT message per ticket".
- Make each door idempotent on its natural platform key —
MessageID, comment.id,
Slack event id, Discord thread id — rather than re-deriving dedup per door.
- Have
handleNewTicket upsert-or-return on (source, sourceId) so the shared handler
is safe by construction and the bots inherit it.
Needs tests per door for the redelivery case; none exist today.
Outpost is meant to answer exactly one message per ticket. That invariant is enforced
per ticket (see the guard in
packages/outpost/queue/src/handlers/ai-response.ts), soit holds only as long as one conversation maps to one ticket. None of the four inbound
doors guarantee that: every one of them will happily create a second ticket for a
message it has already seen, and the second ticket gets its own AI answer. From the
reporter's side that is indistinguishable from the bot answering twice.
Found by the review round on the one-response-per-ticket change; deferred because the
fix needs a schema migration and touches all four doors, which is a different blast
radius than that change had.
Sites
packages/outpost/shared/src/platforms/inbound.ts:158—handleNewTicketdoes nofind-first on
(source, sourceId), and there is no unique constraint backing it(
schema.prismahas an index only). A redelivered thread-start creates a secondticket and a second answer.
apps/web/src/app/api/webhooks/postmark/route.ts:102— noMessageIDidempotency.Postmark retries on any non-2xx, and the handler returns 500 if
createJobthrowsafter
ticket.createsucceeds, so the retry duplicates both.apps/github-app/src/webhooks/issue-comment.ts:53— nocomment.ididempotency;redelivery duplicates messages.
apps/slack-bot/src/events/message.ts:41— no thread-start dedupe.apps/discord-bot/src/events/thread-create.ts:75— a duplicateThreadCreateduplicates ticket and answer.
Also fixed by the same constraint
packages/outpost/queue/src/handlers/ai-response.ts:97— the one-response guard ischeck-then-write.
AI_RESPONSEruns at concurrency 4 (apps/worker/src/index.ts:63),so two jobs for the same ticket can both read "no BOT row yet" and both answer. A
transaction around the check does not close this across connections; a uniqueness
constraint does. That is why it belongs here rather than in the guard's own PR.
Suggested shape
Ticket(source, sourceId)wheresourceIdis non-null, pluswhatever partial-unique expresses "at most one AI-generated BOT message per ticket".
MessageID,comment.id,Slack event id, Discord thread id — rather than re-deriving dedup per door.
handleNewTicketupsert-or-return on(source, sourceId)so the shared handleris safe by construction and the bots inherit it.
Needs tests per door for the redelivery case; none exist today.