Skip to content

Inbound idempotency: redelivered events create duplicate tickets, so a conversation can get more than one AI answer #169

Description

@NathanTarbert

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:158handleNewTicket 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

  1. 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".
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: aiAI response pipeline, groundedness, confidenceroadmapTracked on the Outpost roadmaproadmap: nowRoadmap horizon: V1 launch critical path / safety

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions