Skip to content

fix(ai): answer each ticket once, and stop showing reporters internal ticket IDs - #170

Closed
NathanTarbert wants to merge 40 commits into
mainfrom
fix/one-ai-response-per-ticket
Closed

fix(ai): answer each ticket once, and stop showing reporters internal ticket IDs#170
NathanTarbert wants to merge 40 commits into
mainfrom
fix/one-ai-response-per-ticket

Conversation

@NathanTarbert

@NathanTarbert NathanTarbert commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The rule

Outpost answers exactly one message per ticket — the message that opened it — and never posts in that thread again, regardless of who speaks next. Not the original reporter, not a teammate, not a third party. The agent is a first line of defence; a human owns the thread from the first response onward.

Team-member detection is irrelevant to this decision and no longer gates it.

Why

Two real threads in the CopilotKit Discord:

  • 1535447155735789708 — a maintainer posted the real solution; the bot replied 14 seconds later summarising that answer back at the thread.
  • 1531971013791711342 — a maintainer asked a community member a follow-up question; the bot answered the maintainer, explaining it "can't see other users' replies".

The same thread also opened with 🎫 Ticket TKT-4HS8SRR3 created, publishing an internal identifier into a public server.

How the rule is enforced

Three layers, because no single one is sufficient:

1. Enqueue sites refuse. Replies no longer enqueue an AI_RESPONSE job on any platform — shared/platforms/inbound.ts handleReply (Discord, Slack, Teams, GitHub issue bodies), github-app/webhooks/issue-comment.ts (its own path, which bypassed InboundHandler entirely), web/api/webhooks/postmark/route.ts, and discord-bot/lib/shadow-mode.ts so shadow mirrors production. Only three enqueue sites remain, all new-ticket-only.

2. The database elects one primary response per ticket. Message gained a nullable responseKey with a unique index on (ticketId, responseKey). Exactly one PRIMARY_AI_RESPONSE row can exist per ticket, so concurrent jobs cannot both pass a check-then-write — the loser catches a narrowly-identified P2002 and stops. This matters because AI_RESPONSE runs at concurrency 4. The column is nullable specifically so historical BOT rows stay valid and still count as proof a ticket was answered.

3. A re-answer gate in the handler. Reads the ticket's own message history, so a caller added later inherits the rule.

Layer 1 is the load-bearing one. This is the correction I most want reviewed: the first version of this branch described the handler gate as making the invariant "unroutable-around", with the enqueue-site removals as a cost optimisation. That was backwards — a ticket freshly minted around a mid-thread reply carries no prior AI response and sails straight through the gate. The gate prevents re-answering; the enqueue refusals are what enforce one-answer-per-ticket. The comments now say so.

Delivery is now a state machine, not a hope

The original guard had a hole: the BOT marker was written before the platform post-back, so a failed post left a ticket that looked answered while the reporter got silence — and the guard then blocked the retry. Strictly worse than the over-responding it fixed.

Message.responseState is now PENDINGDELIVERED | ESCALATED:

  • A response starts PENDING before any external post.
  • Successful delivery with no human handoff marks it DELIVERED.
  • A response needing escalation stays PENDING until that job is durable, then becomes ESCALATED.
  • If delivery itself ends PENDING, a retry schedules a delayed check that pulls in a human only if the response is still pending — preserving the one-post rule without racing the original handler.

Every transition is a compare-and-set (updateMany guarded on responseKey + responseState), never a blind update by id.

Internal ticket IDs no longer reach reporters

  • Discord and Slack 🎫 Ticket TKT-… created acknowledgment posts removed entirely — they leaked an internal identifier and spent a bot message on nothing the reporter could act on.
  • All three Teams Adaptive Cards no longer render a displayId. It was also dead payload in the response card's Action.Submit data — card-actions.ts ignores data and resolves by conversation id — so it's gone from there too.
  • The Teams ack card no longer claims "our AI assistant is reviewing your question" when no AI job was enqueued.
  • displayId stays in the dashboard, team slash commands, and logs.

Also fixed here

Area Fix
inbound.ts The orphaned-reply fallback called handleNewTicket, which enqueues — so a mid-thread reply became a "new ticket" and got answered. Reported independently by 11 of 20 review agents; reachable on Teams in particular. handleNewTicket now takes an explicit { answer: boolean }.
inbound.ts sourceId was written as threadId ?? null but looked up as threadId ?? '' — a threadId-less message could never match, so every message minted a new ticket. Now one shared buildTicketSourceId helper across four call sites.
ai-response.ts Answered the latest USER message, not the opening one, so a follow-up racing the job stole the ticket's single answer.
reopen sets The three reply paths disagreed on which statuses a customer reply reopens — GitHub omitted CLOSED, email omitted WAITING_ON_CUSTOMER. Load-bearing because of this change: reopening is the only human signal left. Now one shared predicate.
generator.ts Only read content[0], silently discarding text from multi-block model responses. Now concatenates all text blocks in order and throws on an empty result rather than publishing nothing.
worker.ts Stale PROCESSING jobs are reclaimed after a grace period, with Job.claimToken fencing so a reclaimed job's original owner cannot write back over the new one. Graceful shutdown shares one drain promise, so signal handlers and the app can't each half-drain.
Teams Replies are gated by monitored channel — the filter previously only ran for thread starts, so a reply in an excluded channel reached the handler.
Postmark Reply detection used only the plus-address MailboxHash, so a reply without one became a second ticket and spent an AI answer on a mid-conversation message. Now falls back to In-Reply-To/References, resolving against Ticket.sourceId and Message.attachments.postmarkMessageId. References is what makes this work at all: replies to mail we sent name an ID we never stored, since nothing outbound has ever been sent.
Postmark Ticket creation is idempotent, backed by a partial unique index on Ticket(sourceId) WHERE source = 'EMAIL'. Orphaned email replies are kept out of the AI queue.

Tests

1902 pass across all 10 packages (was 1778 before this branch); typecheck green 10/10; CI green.

Every behavioural fix carries red-green verification — test written, source broken, RED confirmed, restored, GREEN confirmed. Eleven pre-existing tests asserted the old behaviour and were rewritten, including github-app and discord-bot cases that actively locked in "answer every follow-up".

Notable test repairs, because they were passing while proving nothing:

  • The Teams leak guards were mutation-proven unable to fail — the helper read only top-level body[].text, skipping nested containers and action titles. Now a recursive walker fed leak-carrying inputs, five mutations verified RED.
  • Deleting && m.isAiGenerated from the gate predicate left the whole suite green. Both halves are now independently pinned.
  • Three team-member tests this change silently turned tautological were deleted or repointed onto the new-ticket path.

The gate tests mock the AI pipeline at the class seam rather than driving LLMock, because the assertion they exist to make is that no model call happens at allgenerateSupportResponse not being called is the direct expression of that.

Deliberately not in this PR

  • #169 — inbound idempotency, rescoped. The per-ticket claim race and Postmark MessageID idempotency are done here. Still open: no generic (source, sourceId) uniqueness (only EMAIL has an index), no find-first-or-upsert in handleNewTicket, and no dedupe on GitHub comment.id, Slack thread starts, or Discord ThreadCreate.
  • #171 — whether email auto-answers at all. EMAIL is a registered delivery platform but EmailPostmarkAdapter.postResponse throws not yet implemented, so an email ticket generates an answer, stores it, fails to deliver, and escalates. Either unregister EMAIL or implement sending — a product call, and option two starts mailing customers, so it is not being decided inside this PR. Reply detection for email is fixed here (see below).
  • The escalation copy. Responses open with "we've escalated this to our engineering team" and nothing enforces it. Deferred by request.
  • Six clusters recorded during review: Discord shadow-mode parity, Teams card-action correctness (unordered findFirst can mutate the wrong ticket), GitHub webhook robustness, the Postmark email door, Slack subtype coverage, Teams card markdown injection.

Reviewer notes

Provenance, stated plainly. ~22 of these commits were authored in a separate session from the first 13. I have since read their diffs and the description above reflects the branch as it actually stands. The full agent review round ran against the first 13 commits only; the later work has not had an equivalent adversarial pass, so treat CI-green as "verified", not "reviewed".

Where I would look hardest:

  1. The orphaned-reply assumption — it creates a ticket but never answers it. Dropping a customer's message seemed worse than an unanswered ticket a human picks up from the dashboard, but that's a product call.
  2. conversationHistory deliberately still includes an interim follow-up even though the question is the opening message. Question = what to answer, history = what is known.
  3. apps/teams-bot/src/__tests__/cards.test.ts was a hand-merge of two agents that both rewrote it; the first two merge attempts had brace errors. A hand-merged test file is exactly where a guard quietly stops guarding.
  4. The migrations. All additive — nullable columns, a new enum, partial unique indexes where NULLs stay legal, no backfill. Plain CREATE UNIQUE INDEX rather than CONCURRENTLY, which is correct since Prisma wraps migrations in a transaction, but it does take a brief write lock.
  5. Neither Teams card builder sanitizes caller-supplied title/reason, so call-site discipline is all that keeps a TKT- out of card copy. Both current call sites are safe. Filed, not fixed.

…rters

Outpost was replying to every message in a support thread. In the Discord
thread 1535447155735789708 a maintainer posted the real solution and the bot
answered 14 seconds later, summarising that answer back at the thread. In
thread 1531971013791711342 the bot answered a maintainer's question to a
community member. The agent is a first line of defence; a human owns the
thread from the first response onward.

The invariant is now: exactly one AI response per ticket, on the message that
opened it, with no follow-up to any later message regardless of sender --
original reporter, third party, or team member. Team-member detection is
irrelevant to this decision and no longer gates it.

Enforced in two places. The AI_RESPONSE handler drops any job for a ticket
that already carries an AI-authored BOT message; because it reads the ticket's
own history rather than trusting its caller, a future enqueue site cannot
reintroduce the behaviour. Reply paths additionally stop enqueuing, so a job
that would be dropped is never paid for:

  - packages/outpost/shared/src/platforms/inbound.ts (Discord, Slack, Teams,
    and GitHub issue bodies -- status transitions retained)
  - apps/github-app/src/webhooks/issue-comment.ts (its own path, which
    bypassed InboundHandler entirely)
  - apps/web/src/app/api/webhooks/postmark/route.ts (email replies)
  - apps/discord-bot/src/lib/shadow-mode.ts (so shadow mirrors production)

Second, internal ticket displayIds no longer reach reporters. The Discord and
Slack "Ticket TKT-XXXXXXXX created" acknowledgment posts are removed outright
-- they published an internal identifier into a public server and spent a bot
message on nothing the reporter could act on. The three Teams Adaptive Cards
drop displayId from their text; buildResponseCard keeps it in the
Action.Submit data payloads so button clicks still resolve to a ticket.
displayId remains in the dashboard, in team slash commands, and in logs.

Call-site enumeration:

  - buildTicketCreatedCard (option removed: ticketDisplayId) -- 1 call site,
    apps/teams-bot/src/handlers/message.ts:82, updated. Tests updated.
    No remaining references to the removed option.
  - buildEscalationCard (option removed: ticketDisplayId) -- 1 call site,
    apps/teams-bot/src/handlers/card-actions.ts:106, updated. Tests updated.
    No remaining references to the removed option.
  - buildResponseCard (signature unchanged; body text changed) -- 1 call site,
    apps/teams-bot/src/lib/teams-poster.ts:23. Assumption still holds: it
    passes ticketDisplayId, which is still consumed, now only for action
    routing. PlatformTeamsAdapter has a separate private buildResponseCard
    (packages/outpost/shared/src/platforms/teams.ts:269) that never rendered
    a displayId -- unaffected.
  - InboundResult.aiJobEnqueued (semantics changed: always false on replies)
    -- 1 consumer, apps/discord-bot/src/events/message-create.ts:68, which
    only appends a log suffix. Assumption still holds; the log is now
    accurate rather than misleading.
  - handleAiResponse, handleShadowMessage, handleReply -- signatures
    unchanged; handleReply is private with no external callers.

Tests: 1778 pass across all 10 packages. 11 existing tests asserted the old
behaviour and were rewritten -- notably the github-app and discord-bot cases
that locked in "answer every follow-up". Six new tests cover the handler
guard, including that a human reply after the AI response still does not
trigger a second answer, that a first response is not swallowed, and that a
SYSTEM shadow-mode log is not mistaken for the ticket's answer. Red-green
verified: neutralising the guard turns them red.

The guard tests mock the AI pipeline at the class seam rather than driving
LLMock, because the assertion they exist to make is that no model call happens
at all -- generateSupportResponse not being called is the direct expression of
that.

Not addressed here: the response template claims "we've escalated this to our
engineering team" without anything enforcing it. Wiring the copy to real
escalation is deferred by request.
The escalation and ticket-created card doc comments carried the literal
six-character sequence \u2014 where an em dash was meant; in a comment
that renders as raw escape text. response-card.ts already used the real
character, so the diff was internally inconsistent.

Comment-only; no behavior change. The escape sequences in the card
bodies' text: string values are the pre-existing convention and are
left as-is.

(cherry picked from commit ac6540eaeec0cb16f854fae58bf4ad90b70b24ae)
The doc comment added in e415e31 claimed the internal ticketDisplayId was
"routed through the Action.Submit `data` payloads so button clicks resolve
back to the ticket". That was false. Both handlers in
apps/teams-bot/src/handlers/card-actions.ts take the payload as `_data`
(unused) and resolve the ticket via
findTicketByConversationId(context.activity.conversation.id). Nothing ever
read data.ticketDisplayId, so the identifier was dead payload that still
shipped to the reporter's Teams client inside the card JSON.

Removed it from ResponseCardOptions, from both Action.Submit `data`
payloads, from PostResponseOptions in lib/teams-poster.ts, and from
CardActionData (the receiving half of the same dead field — it declared a
required string that never arrives). Doc comments now state what is true:
the card carries no identifier anywhere, the handlers resolve by
conversation id, and this module is currently unreachable in production —
nothing imports teams-poster.ts and the worker posts
PlatformTeamsAdapter.buildResponseCard from
packages/outpost/shared/src/platforms/teams.ts instead. Module kept, just
made correct.

Call-site enumeration (grep 'ticketDisplayId|buildResponseCard|postAiResponse|ResponseCardOptions|PostResponseOptions' across the repo):

- ResponseCardOptions.ticketDisplayId (removed) — only producer was
  lib/teams-poster.ts, updated in this commit. No other reference. Holds.
- buildResponseCard (apps/teams-bot/src/cards/response-card.ts) — two
  references: lib/teams-poster.ts (updated) and
  src/__tests__/cards.test.ts (updated). Both now pass only
  {responseText, confidence}. Holds; tsc --noEmit is clean, which is the
  proof there is no third caller.
- PostResponseOptions.ticketDisplayId (removed) — NEGATIVE FINDING: nothing
  imports lib/teams-poster.ts at all (no src file, no test). postAiResponse
  here has zero call sites, so removing the field breaks no caller.
- CardActionData.ticketDisplayId (removed) — only read site was the
  destructure-free `_data` params of handleIssueSolved /
  handleNeedMoreHelp, both of which ignore it. NEGATIVE FINDING: no
  surviving card emits the field either — buildEscalationCard and
  buildTicketCreatedCard define no `actions`, and
  PlatformTeamsAdapter.buildResponseCard
  (packages/outpost/shared/src/platforms/teams.ts:269) returns a card with
  no `actions` array at all, so the production path never sends button
  payloads. No backward-compat payload is lost.
- Same-named symbols in sibling apps are unrelated and untouched:
  apps/slack-bot/src/lib/slack-poster.ts still uses ticketDisplayId for
  Slack block_id / action `value` (Slack routes by action value there, a
  genuinely live use), and apps/github-app/src/lib/github-poster.ts has its
  own PostResponseOptions with no such field. Neither imports the Teams
  card module. Assumptions unaffected.
- Test fixture apps/teams-bot/src/__tests__/card-actions.test.ts
  makeCardContext no longer stuffs ticketDisplayId into activity.value, so
  the suite now proves the handlers resolve the ticket without it.

Tests: the old "still routes the displayId through action data so clicks
resolve" case encoded the false claim and is replaced by "carries no ticket
identifier anywhere — not in text, not in action data", which asserts
Object.keys(action.data) === ['action'] for both buttons plus a
JSON.stringify catch-all against /[Dd]isplayId|TKT-/.

Red-green: re-added ticketDisplayId to the issue_solved payload → new test
failed on the action-data key assertion (RED); restored → 55/55 pass
(GREEN). `npx vitest run --reporter=dot` and `npx tsc --noEmit` both clean
in apps/teams-bot.

(cherry picked from commit bf5f70e609ac31c62f26a78b401b3e499e69cdb8)
The guards added with the leak fix could not detect a regression:

- allCardText only read top-level body[].text, skipping nested
  containers and every actions[].title.
- The ticket-created and escalation guards were tautological — neither
  builder accepts a displayId, so no input could have carried a leak.

Replace the helper with visibleCardStrings, a recursive walk over the
whole card that collects every string except Action.Submit data payloads
(a denylist, so an unknown rendered field still trips the guard), and
add a meta-test pinning that the walker reaches nested containers and
action titles.

Feed each guard an input that WOULD leak if the builder passed it
through: the response card already receives the displayId (now checked
in both the high- and low-confidence body shapes), and the ack /
escalation guards hand the builders a ticketDisplayId property they
currently ignore. Also pin that title/reason are echoed verbatim, so the
caller-owned boundary is documented rather than assumed.

Tests only — no production behavior change.

(cherry picked from commit 16b38d2639be56cb6dfe599424efb414ec754e94)
The three inbound reply paths each carried their own literal list of the
ticket statuses a customer reply reopens, and they had drifted:

  shared InboundHandler    WAITING_ON_CUSTOMER, RESOLVED, CLOSED  (reference)
  github-app issue-comment WAITING_ON_CUSTOMER, RESOLVED          (no CLOSED)
  postmark webhook                              RESOLVED, CLOSED (no WAITING_ON_CUSTOMER)

That is now load-bearing: replies no longer enqueue an AI_RESPONSE, so the
reopen is the ONLY signal a customer follow-up sends to a human. A comment on
a CLOSED GitHub ticket reached nobody, and an email reply to a ticket waiting
on the customer stayed out of the queue.

Fix: one exported predicate in @copilotkit/outpost/shared -
REOPEN_ON_CUSTOMER_REPLY_STATUSES + reopensOnCustomerReply(status) - and all
three paths call it. No path keeps a literal status list.

Tests (red-green verified):
- shared: CLOSED reopen case (was uncovered) + a reopensOnCustomerReply unit
  block, including an exhaustiveness check over TicketStatus so a new status
  cannot be added without deciding whether a reply reopens it.
- github-app: it.each over the three dormant statuses (CLOSED was uncovered)
  plus a negative it.each over OPEN/IN_PROGRESS/WAITING_ON_TEAM.
- apps/web postmark: same pair (WAITING_ON_CUSTOMER was uncovered).
- Both app tests now spread importActual over @copilotkit/outpost/shared
  instead of stubbing it wholesale, so they exercise the real predicate.
- RED confirmed three ways: reverting the github condition fails 1 github
  test; reverting the postmark condition fails 1 web test; dropping CLOSED
  from the shared constant fails 3 shared + 1 github + 1 web test. GREEN
  restored after each.

Verification: packages/outpost 969 tests / 60 files pass, typecheck clean;
apps/github-app 43 tests pass, tsc --noEmit clean; apps/web 541 tests /
47 files pass, tsc --noEmit clean; eslint clean on the four source files.

Call-Site Enumeration
---------------------
Added: REOPEN_ON_CUSTOMER_REPLY_STATUSES, reopensOnCustomerReply
(packages/outpost/shared/src/constants.ts). Already public: constants.ts is
re-exported wholesale by shared/src/index.ts ("export * from './constants.js'"),
so no new line was needed in the entry point; verified by apps/web and
apps/github-app importing from '@copilotkit/outpost/shared' and typechecking.

References to the two new symbols (every one):
- shared/src/platforms/inbound.ts:16,266 - handleReply. Holds: the value is
  the exact set this file used to inline, so the reference-set behaviour is
  unchanged; its existing WAITING_ON_CUSTOMER/RESOLVED tests plus a new CLOSED
  test pass.
- shared/src/__tests__/platforms-inbound.test.ts:6,621-653 - new unit block.
  Holds: asserts the set contents, the negative statuses, null/undefined
  safety, and TicketStatus exhaustiveness.
- apps/github-app/src/webhooks/issue-comment.ts:4,89 - non-team commenter
  branch. Holds: ticket.status comes off the Prisma row as a string, which is
  exactly the predicate's parameter type; the team-member branch above is
  untouched (WAITING_ON_TEAM -> WAITING_ON_CUSTOMER stays its own rule).
- apps/web/src/app/api/webhooks/postmark/route.ts:13,89 - existing-ticket
  reply branch. Holds: same string status; the "updatedAt: new Date()" in the
  update payload is preserved. This path has no team-member detection, so
  every inbound email on a ticket is a customer reply by construction -
  applying the customer predicate is correct here.
- apps/web/src/__tests__/postmark-webhook.test.ts:28 - comment only.

Removed: three inline status literals. No exported symbol was removed, so no
external caller could depend on them. Confirmed by grep that no literal reopen
set remains - "=== 'RESOLVED'" / "=== 'CLOSED'" now hit only two unrelated
sites.

Negative findings (checked, deliberately NOT converted):
- packages/outpost/shared/src/sla/checker.ts:67 - "CLOSED || RESOLVED" stops
  the SLA clock. Different question (is the ticket finished), and it must not
  include WAITING_ON_CUSTOMER; left alone.
- apps/web/src/app/api/accounts/[id]/route.ts:43 - "CLOSED || RESOLVED"
  counts finished tickets for an account. Same reason; left alone.
- apps/discord-bot, apps/slack-bot, apps/teams-bot - no literal reopen set;
  they all reply through the shared InboundHandler, so they pick the fix up
  for free. Their existing reopen tests still pass.
- apps/linear-sync/src/webhooks/sync-handler.ts:19 - declares its own
  TicketStatus string union but never gates a reopen on it; no change.
- apps/discord-bot/src/lib/shadow-mode.ts:72 and packages/outpost/db/src/seed.ts
  - write "status: 'OPEN'" on ticket CREATE, not a reopen; unaffected.

(cherry picked from commit 4006b902c8e565883863b3a11e36be66b54e4266)
Create stored the ticket's sourceId one way and reply looked it up another:
`message.threadId ?? null` on create vs `message.threadId ?? ''` on lookup.
With no threadId the two could never agree, so every message in that
conversation looked like a brand-new ticket and drew its own AI answer --
defeating the one-answer-per-ticket rule this branch enforces. The Slack
composite key was asymmetric the same way (create required BOTH channelId
and threadId; the lookup built "channelId:" from channelId alone), and
apps/slack-bot/src/events/message.ts built a third variant,
"C123:undefined".

Root cause was three-way duplication of the key-building logic, so the fix
is one definition -- shared/src/platforms/source-id.ts's
buildTicketSourceId(source, threadId, channelId) -- that both the writer and
every reader derive the key from. findTicketBySourceAndThread(source,
threadId, channelId) becomes findTicketBySourceId(source, sourceId): the
reader takes the finished key and has no key-building code left to disagree
with.

Deliberate decision for "no threadId": the helper returns null, not a
placeholder. A ticket with no thread key cannot be found again by any
lookup, so writers store null (the ticket is still created -- dropping a
report is worse) and readers treat null as not-lookup-able and skip the
query entirely rather than searching for '' or "C123:", which can only be a
false miss or a false hit on a malformed row. Slack with no channelId is
also null now instead of a bare thread_ts: a thread_ts is only unique within
a channel, and both Slack post paths already threw for those tickets because
ticket.channel is null in exactly that case. Documented in the module's doc
comment along with the "never inline `${channelId}:${threadId}`" rule.

Tests, red-green verified: reverting inbound.ts to the two inline paths gives
5 failed / 37 passed, widened to 8 failed / 38 passed once the unaddressable
rows join the write/read symmetry table; breaking the helper to return
placeholders gives 3 failed / 3 passed in the new unit test; restored, all
green. Covers the previously-untested Slack empty-threadId lookup arm, the
Slack no-channelId arms, and the no-threadId case for a non-Slack source
(create stores null, reply issues no query, and a threadId-less reply is not
matched against a sourceId: null ticket).

Verified: packages/outpost `vitest run` 984 passed / `tsc -p
shared/tsconfig.json --noEmit` clean; slack-bot 52, discord-bot 57,
teams-bot 56, github-app 37 passed; `tsc --noEmit` clean in all four apps.
eslint cannot run repo-wide (no flat eslint.config.*) -- pre-existing.

Out of scope, untouched: the (source, sourceId) uniqueness constraint / any
Prisma migration, and the Slack subtype filter -- both filed as separate
follow-ups.

Call-Site Enumeration
---------------------
ADDED buildTicketSourceId -- grep -rn "buildTicketSourceId" --include="*.ts",
all 7 references:
- shared/platforms/inbound.ts handleNewTicket: holds. Wants the key to store;
  null is a valid Ticket.sourceId (nullable column, PrismaLike.create already
  types it string|null).
- shared/platforms/inbound.ts handleReply: holds. Wants the key to search for;
  narrows null before calling findTicketBySourceId(...: string).
- shared/platforms/index.ts re-export: holds. Value export from a module that
  imports only ../types.js, so no adapter runtime is pulled in.
- shared/index.ts re-export: holds. Same reasoning -- the barrel's
  "platform types only, browser-safe" contract is preserved; justified inline.
- apps/slack-bot/src/events/message.ts: holds. source is always
  TicketSource.SLACK there (SlackAdapter.parseInboundEvent sets it), so the
  Slack arm applies; a null key returns early instead of querying.
- apps/slack-bot/src/lib/tickets.ts findTicketByThreadTs: holds. Its callers
  (events/actions.ts:22,:74, commands/assign.ts) were written against findFirst
  and already treat null as "not found", so an early null is indistinguishable.
- shared/__tests__/platforms-source-id.test.ts: holds -- unit tests of it.

REMOVED findTicketBySourceAndThread -- 2 hits before (definition + its single
call, both in inbound.ts), 0 hits repo-wide after. It was private, so no
external consumer was even representable. Negative finding: nothing dangles.
Near-miss namesake apps/github-app/src/lib/tickets.ts:11 exports a different
findTicketBySourceId(sourceId) -- separate package, not imported here, not
touched, no collision.

CHANGED the sourceId value written on create -- every writer/reader in the repo:
- apps/web/.../webhooks/postmark/route.ts:112 writes body.MessageID: holds.
  Not routed through InboundHandler, and the helper is the identity for EMAIL,
  so the formats already agree if it ever is.
- apps/discord-bot/src/lib/shadow-mode.ts:76 writes thread.id: holds --
  identical to buildTicketSourceId(DISCORD, thread.id).
- apps/discord-bot/src/lib/tickets.ts:10 and apps/teams-bot/src/lib/tickets.ts:10
  read the bare threadId/conversationId: holds. Non-Slack keys are verbatim, so
  these match what create stores. Left inline deliberately -- for non-Slack the
  builder is the identity function, there is no format to duplicate.
- apps/github-app/src/lib/tickets.ts:11,28 read a prebuilt owner/repo#n: holds,
  identity for GITHUB_ISSUE/GITHUB_DISCUSSION, unchanged.
- shared/platforms/slack.ts:178 extractThreadTs splits on the first ':': holds.
  The composite format is byte-identical; only would-be bare Slack keys became
  null, and those tickets already threw on both post paths.
- shared/platforms/github.ts:418 parseSourceId regex: holds, GitHub keys
  unchanged. queue/handlers/github-reaction-poll.ts:22,87 likewise, and it
  already null-guards ticket.sourceId.
- queue/handlers/ai-response.ts:276 passes ticket.sourceId to the adapter:
  holds -- already string|null per PlatformAdapter.postResponse, and adapters
  throw loudly on null, the correct outcome for an unaddressable ticket.
- shared/platforms/{discord,teams,email-postmark}.ts post paths: hold. All
  already null-guard sourceId and throw a clear error -- no silent misroute.
- TicketSource is still imported in inbound.ts (used by toPlatformTarget), so
  the import is not dead; confirmed by clean tsc.

(cherry picked from commit 33fd91ca68ca1387f29d87e30d0a8afec79a626c)
handleReply stopped enqueuing AI_RESPONSE, but on a ticket-lookup MISS it
fell back to `handleNewTicket({ ...message, isThreadStart: true })` — and
handleNewTicket enqueues. A mid-thread reply became a brand-new "ticket"
titled with the follow-up text, and the bot answered it. The one-answer-per-
ticket rule was routed around by the same file that documents it.

Reachability was not theoretical:
- apps/slack-bot/src/events/message.ts pre-filters replies whose thread is
  untracked, so Slack was shielded.
- apps/teams-bot/src/handlers/message.ts has NO such pre-filter and its
  monitored-channel gate only runs for thread starts, so a Teams reply
  reached the fallback.
- Discord and GitHub reached it for any thread predating Outpost.

handleNewTicket now takes an explicit `{ answer: boolean }` decision from its
caller instead of inferring one. handle() passes `{ answer: true }` for a
genuine thread start; the orphaned-reply fallback passes `{ answer: false }`.
No duplication of handleNewTicket's body, and InboundResult.aiJobEnqueued
stays truthful (false on that path).

ASSUMPTION, stated in the code comment so it is reviewable: an orphaned reply
still CREATES a ticket and persists the message — dropping a customer's
message is worse than filing an oddly-titled ticket — but it is never
answered, because the message that opened the real conversation was never
seen by us. A human picks it up from the dashboard.

Also corrects the now-falsified claim in queue/src/handlers/ai-response.ts
that the ticket-history gate makes the invariant "unroutable-around". That
gate only sees messages on the ticket, so a ticket freshly minted around a
mid-thread message has no prior AI response and sails through it. The gate is
a re-answer guard, not a total gate; the orphaned-reply case must be refused
at the enqueue site, and the comment now says so and points at it.

Tests: both pre-existing fallback tests asserted nothing about createJob,
which is exactly why this shipped. Both now assert it, plus new coverage for
Teams/Discord/GitHub/Slack orphaned replies, a non-team-member sender (the
case that WOULD have been answered), and a genuine thread start still being
answered so the fix is not a blanket mute.

Red-green verified: with `{ answer: false }` flipped to `{ answer: true }`,
7 tests fail in packages/outpost (2 files) and 1 in apps/teams-bot; restored,
all green.

Call-Site Enumeration
---------------------
Symbols changed: `InboundHandler.handleNewTicket` (signature — added required
second param `{ answer: boolean }`).
Symbols added: none exported. Symbols removed: none.

`handleNewTicket` — private; grep over the repo (excluding node_modules)
returns exactly three hits, all in shared/src/platforms/inbound.ts:
  - :147 the declaration.
  - :133 `handle()` thread-start branch → passes `{ answer: true }`.
    Assumption holds: isThreadStart=true is the opening message, the one
    message Outpost may answer. Behavior byte-identical to before.
  - :252 `handleReply` orphan fallback → passes `{ answer: false }`.
    Assumption deliberately INVERTED here; that is the fix.
  No external caller exists and none can be added by accident: the method is
  `private` and is not re-exported from platforms/index.ts or shared/index.ts
  (verified — those export the class, InboundHandlerConfig, CreateJobFn only).

`handleReply` — private; hits at :135 (sole caller, the non-thread-start
branch of handle()) and :226 (declaration). Signature unchanged; its return
type and every non-orphan path are untouched. Third hit is a comment
reference in queue/src/handlers/ai-response.ts:100 (prose, no call).

`InboundResult.aiJobEnqueued` — non-test consumers:
  - shared/src/platforms/types.ts:165 — the declaration, `boolean`, unchanged.
  - apps/discord-bot/src/events/message-create.ts:68 — logs
    " (AI job enqueued)" when true. Assumption still holds and is now MORE
    accurate: on an orphaned Discord reply the log no longer claims an
    enqueue that would have happened. Log text only, no control flow.
  NEGATIVE FINDING: no other runtime code reads aiJobEnqueued — not the Slack,
  Teams, or GitHub bots, not the queue, not the dashboard. grep for the
  identifier over apps/ and packages/ returns only the above plus test files.
  So no caller branches on it and no caller can be broken by it flipping to
  false on this path.

`isTeamMember` — still called exactly where it was, now guarded by `answer &&`
short-circuit so the lookup is skipped when no answer is possible. NEGATIVE
FINDING: the returned value was used for nothing but the enqueue decision in
handleNewTicket (the reply path calls it separately for its own status
transitions, untouched), so skipping the query cannot change any other
observable behavior. The reply path's own isTeamMember call at :281 is
unaffected.

`handle` (public entry point) — signature and return type unchanged; all bot
call sites (apps/discord-bot thread-create.ts + message-create.ts,
apps/slack-bot events/message.ts, apps/teams-bot handlers/message.ts,
apps/github-app webhooks/issues-opened.ts + discussion-created.ts) compile and
pass unchanged. NEGATIVE FINDING: no bot needed a per-platform change; the fix
is entirely in the shared handler, so all four platforms are covered at once
and no bot can opt out.

`ai-response.ts` change — comments only, zero code touched, so it has no call
sites and no type surface.

Verification
------------
- packages/outpost: `npx vitest run --reporter=dot` → 60 files, 972 tests, all
  passing. (`npx prisma generate` was needed first in this fresh worktree;
  without it queue/src/__tests__/scheduler.test.ts fails to load on a missing
  .prisma/client — pre-existing env setup, not a code failure.)
- `npx tsc --project shared/tsconfig.json --noEmit` → clean.
- apps/teams-bot 57, apps/discord-bot 57, apps/slack-bot 50,
  apps/github-app 37 — all passing.

(cherry picked from commit 6f14b00703f954280922af044bc2500337ef41f9)
The one-response-per-ticket guard reads the BOT Message row, which is
committed BEFORE the platform post-back. That made two pre-existing
soft-failure paths permanent:

1. The `suggestedResponse` ticket.update ran unguarded between the BOT row
   and post-back. A throw there aborted the job; every retry then hit the
   guard, returned success with skipped: true, and nothing was posted or
   escalated.
2. `adapter.postResponse` throwing was logged and the job still reported
   success. Before the guard a manual re-enqueue could still deliver the
   answer; after it, that door is closed.

Either way the reporter is silent forever while the DB says they were
answered. The guard is the requirement, so it is untouched — instead every
path where the answer failed to reach the reporter now hands the thread to a
human in the same run:

- `suggestedResponse` write is non-fatal (logged); it can no longer strand
  the job before delivery is attempted.
- postResponse throwing, and getAdapter throwing, record a deliveryFailure.
- deliveryFailure enqueues ESCALATION with a reason naming the failure, and
  wins the reason slot over suppression / low confidence (most actionable).
- If that ESCALATION enqueue also fails, the job returns success: false so
  the worker records a failed attempt with the reason on the job row — the
  one outcome with neither delivery nor a human must not look like success.
  Escalation-enqueue failures for low confidence / suppression keep the
  historical success result: there the response did reach the reporter.
- The externalCommentId write moved out of the post-back try so bookkeeping
  failure is not misread as delivery failure.
- Sources with no adapter escalate only if the suggestedResponse write
  failed, since there suggestedResponse IS the delivery path. SHADOW_MODE
  posts nothing by design and never escalates on that basis.

Out of scope (filed separately): the guard's check-then-write race under
AI_RESPONSE concurrency 4, whose fix is a uniqueness constraint plus a
migration. Nothing here narrows or widens that window — no ordering of the
guard read or the Message create changed, and no transaction was added.

Call-Site Enumeration
---------------------
`handleAiResponse` (exported; signature unchanged)
- packages/outpost/queue/src/index.ts:4 re-export — holds, same signature.
- apps/worker/src/index.ts:82 `worker.on(JobType.AI_RESPONSE, ...)` — holds.
  The new success: false path is a JobResult the worker already handles at
  worker.ts:351 via handleFailure (retry ladder + error text persisted on the
  job row). A retry after that failure is skipped by the guard and returns
  success, which completes the job; the recorded error text remains on the
  row, so the operator signal survives. That is intended: the answer is
  undeliverable, so retrying generation is pointless.
- queue/src/__tests__/ai-response.test.ts — updated, all call sites reviewed.
- No other callers (grepped `handleAiResponse` across packages/ and apps/).

JobResult.data key `escalated` (semantics widened: now also true on delivery failure)
- Grepped `escalated` across packages/ and apps/: every hit is unrelated
  (AI disclaimer copy, escalation handler log lines, bot escalate commands)
  except ai-response.test.ts. NEGATIVE FINDING: no production reader of
  AI_RESPONSE's result.data exists — worker.ts only inspects `success` and
  stores nothing from `data` — so widening it breaks nothing.

JobResult.data key `deliveryFailed` (new)
- Only readers are the new tests. No dashboard/API code reads AI_RESPONSE
  job result payloads (grepped `result.data` under queue/src and apps/).

ESCALATION payload `reason` (new value shape for the delivery case)
- queue/src/handlers/escalation.ts:33,66,108,130 — holds. `reason` is used
  only as free text: interpolated into the routing reason, the assignment
  note and a SYSTEM message. No parsing, no enum matching, no length limit.
- apps/* escalate commands construct their own reasons; unaffected.

New locals (`deliveryFailure`, `escalationEnqueueError`, `suggestedResponseError`,
`escalationReason`, `escalated`) and the widened scope of `externalCommentId`
- All function-local to handleAiResponse; no exports, no references outside
  the handler body. `externalCommentId` changed from a `const` inside the
  post-back try to a `let` in the enclosing block; its only consumer is the
  message.update below it, which now runs on the delivered path only.

Tests
-----
Red-green verified for each behaviour: dropping the postResponse
deliveryFailure assignment (3 red), restoring the unguarded suggestedResponse
write (2 red), disabling the success: false return (1 red), and moving the
externalCommentId write back inside the post-back try (1 red).

`npx vitest run` — 973 passed (60 files).
`npx tsc --project queue/tsconfig.json --noEmit` — clean.

(cherry picked from commit 71dd6a9702f1b7811cee7ae2237bfd3233481cb0)
Outpost answers exactly one message per ticket -- the one that opened it
(the invariant the handler already documents and enforces at step 1b).
The question selection contradicted that: it scanned ticket.messages in
REVERSE and took the LATEST type: 'USER' row.

Replies are still persisted as USER messages, and that is correct -- they
belong in the thread's history. The consequence was that a reporter who
split a thought across two Discord messages in the seconds between ticket
creation and the job dequeuing had the ticket's one and only answer aimed
at the follow-up fragment ("btw I'm on the app router") instead of the
question that opened the thread. One shot, spent on the wrong sentence.

ticket.messages is loaded orderBy: { createdAt: 'asc' }, so the fix is a
forward .find() for the first USER row. The ?? ticket.description ??
ticket.title fallback chain is unchanged.

Judgement call -- conversation history stays FULL
------------------------------------------------
conversationHistory still carries every non-SYSTEM message, including any
follow-up that landed after the opening message, even though the QUESTION
is now the opening message. The two inputs answer different questions:
`question` is what to respond to, `conversationHistory` is what the
responder knows. A Discord follow-up is usually the same thought continued
-- a stack trace, a version number, "on Next 15" -- and it is exactly the
detail that makes the single allowed answer good, so truncating history
would trade a targeting bug for a worse answer. Truncation would also
require inventing a second policy for the non-USER rows after the opening,
with no evidence behind it. Consecutive same-role turns are not a new
condition: the generator already appends `question` after the history, so
the base single-message case has always produced two user turns in a row.

Call-Site Enumeration
---------------------
- `latestUserMessage` (removed, function-local const): repo-wide grep over
  *.ts/*.tsx/*.md (excluding node_modules and dist) returns ZERO remaining
  references. Nothing outside the handler could see it; it never escaped
  the function body. Negative finding: no docs or comments named it either.
- `openingUserMessage` (added, function-local const): 1 reference, the very
  next line (line 144). Not exported, not part of any type. No other call
  site can be affected.
- `question` (unchanged name, changed VALUE): 1 consumer inside the handler
  -- `pipeline.generateSupportResponse(question, ...)` at line 175. Its
  contract is "a support question as a string"; still a string, still
  non-empty via the same fallback chain, so the assumption holds. The
  fallback ordering and the SUPPRESSED/groundedness paths downstream are
  value-agnostic and unaffected.
- `pipeline.generateSupportResponse` (signature untouched): other callers
  are packages/outpost/ai/src/pipeline{,-groundedness}.test.ts (pass their
  own literal questions) and apps/web/src/app/api/qa/route.ts (passes the
  user's typed QA question, no ticket involved). Negative finding: none of
  them reads a Ticket's messages, so none inherits this behaviour change.
- `conversationHistory` (unchanged shape and value): consumed by
  AIPipeline.generateSupportResponse -> generator.buildMessages
  (ai/src/generator.ts:222). Unchanged, so its assumptions hold by
  construction.
- `ticket.messages` ordering assumption: the only producer is the
  `orderBy: { createdAt: 'asc' }` include at line 65 of this same file --
  same function, no other loader feeds this variable. The step-1b
  already-answered guard also uses a forward `.find()` and is unaffected.

Tests
-----
New describe block "answers the message that opened the ticket" in
queue/src/__tests__/ai-response.test.ts: the key case (opening USER +
later USER -> generates against the opening, asserted on
mockGenerateSupportResponse.mock.calls[0][0]), the companion assertion
that the interim follow-up still arrives as conversationHistory, a
leading-SYSTEM-row case, and the no-USER-message description fallback.
The pre-existing "filters SYSTEM messages from conversation history" test
encoded the bug in its expected question ('Follow up question') and now
expects the opening ('Hello'); its history assertion is unchanged, which
is what pins the judgement call above.

Red-green verified: with the source reverted to the reverse().find() form,
3 tests fail (the two new targeting tests plus the corrected SYSTEM-filter
test); restored, 968 tests pass.

Gates: `npx vitest run --reporter=dot` in packages/outpost -> 60 files,
968 tests, all passing. `npx tsc --project queue/tsconfig.json --noEmit`
-> clean (sibling packages built first so the workspace `dist` type
entrypoints resolve). Prettier clean on both touched files.

(cherry picked from commit 297e555ac62bd88b519a7fb90d7825b3965f43f4)
The one-response-per-ticket guard returned right after reportProgress(20),
so a job that succeeded by deciding to do nothing persisted progress=20 on
its Job row and read as hung to anything watching job progress. Walk the
ladder to 100 before returning, like every other successful exit.

Failure exits are left as-is on purpose: the Job row carries status FAILED
next to the number, so a partial progress value is the honest reading there
and 100 would falsely claim completion.

(cherry picked from commit a65c2eaef1e088b24719d622468840415bc0a1e0)
The guard's predicate is `m.type === 'BOT' && m.isAiGenerated`, but the
tests only pinned the first half. Deleting `&& m.isAiGenerated` left the
whole suite green, because no fixture carried a BOT row with
isAiGenerated: false — the shape a human reply sent from the dashboard
persists as.

Two changes, tests only:

- Spell out isAiGenerated on every message fixture. The DB column is
  non-nullable, so rows that omit it are a shape the handler never sees,
  and the omission let the guard be satisfied by `undefined`.
- Add the BOT + isAiGenerated: false case: a teammate's reply goes out
  over the bot channel but is not Outpost's one answer, so the AI's
  single response must still be generated and posted.

Mutation-verified: dropping `m.type === 'BOT'` fails the SYSTEM
shadow-mode-log test; dropping `&& m.isAiGenerated` fails the new
human-BOT-reply test (and the SYSTEM-history filter test). Each half is
now independently pinned.

(cherry picked from commit 35610d3fa08c9c37921508de21f36ecf02b765c4)
The one-response-per-ticket change made replies never enqueue an AI_RESPONSE
for any sender, which quietly turned three "team member reply gets no AI
response" tests tautological -- they would keep passing with team-member
detection deleted outright.

Team-member detection still decides aiJobEnqueued on the NEW-TICKET path, so
that is where the assertion belongs.

- packages/outpost/shared/.../platforms-inbound.test.ts: deleted 'skips
  AI_RESPONSE for team member reply'. The reply rule is already covered by two
  explicit tests, and the new-ticket path already has 'skips AI job when sender
  is a team member' plus the whole 'team member detection' block. A comment
  records why no team-member reply test lives there.
- apps/discord-bot: deleted the reply-path test in message-create.test.ts and
  repointed it into thread-create.test.ts (discord's new-ticket path), which had
  no team-member coverage at all.
- apps/slack-bot: repointed in place -- the same handler serves both paths, so
  the test moved from 'threaded replies' to 'new top-level messages'.

Red-green verified with `return false` at the top of InboundHandler.isTeamMember:
both repointed tests fail, and the three surviving shared-package team-member
tests fail with them. Restored -> all green. No production code touched.

(cherry picked from commit ad620a45f82648dff0f408a54b7daff186636d46)
The prose around the one-answer invariant made claims the code does not
support, and framed the enforcement backwards.

Corrections:

- inbound.ts `handle()` / module header: path 1 enqueues AI_RESPONSE only
  when the sender is not a team member, and it is the only path here that
  enqueues at all. Team status no longer gates the reply path in any way.
- ai-response.ts header: the step list omitted step 1b (already-answered
  gate), step 5b (platform post-back), and the SHADOW_MODE branch that
  replaces post-back with a SYSTEM message.
- ai-response.ts gate comment: "Five separate code paths could enqueue
  AI_RESPONSE" was both miscounted and wrong about which. Three enqueue
  sites exist -- InboundHandler.handleNewTicket (Discord, Slack and Teams
  all funnel through it), handleShadowThreadCreate in the Discord
  shadow-mode path, and the Postmark new-email branch.
- The gate is a RE-ANSWER guard, not the enforcement point, and the
  comments now say so everywhere they mention it. A ticket freshly minted
  around a mid-thread reply carries no prior AI response and passes the
  gate untouched; likewise a reply on a ticket Outpost never answered. The
  enqueue-site refusals are what actually hold one-answer-per-ticket, so
  calling them a cost optimisation or "the cheap arm" was backwards.
- source-id.ts: a writer/reader key mismatch no longer means "every reply
  gets its own AI answer" -- an unmatched reply is filed as an untracked
  ticket with no answer. The real damage is the lost reply and the
  duplicate stub.
- shadow-mode.ts: the enqueue comment said the worker calls
  logShadowResponse; it writes the shadow row inline and never calls it.
  The logShadowResponse docstring said NOTE-type; the row is SYSTEM.

Also trimmed the "this used to..." narrative, which was repeated in four
places, down to the two sites where it genuinely stops the bug being
reintroduced.

Comments only -- no behavior change, so no test accompanies it.
Verified with `npx turbo typecheck` and `npx turbo test` (all green).

(cherry picked from commit 69b4c941a8d8ec18e4b70f65c6b214a3c66bf14d)
Call sites audited:
- ResponseGenerator.generate: AIPipeline.generateSupportResponse; generator.test.ts; pipeline-groundedness.test.ts
- ResponseGenerator.generateStream: AIPipeline.generateStreamingResponse; generator.test.ts
- Anthropic content extraction: ResponseGenerator.generate; TicketClassifier.classify; ConfidenceScorer.score; analyzeSentiment
Call sites/state paths audited:
- main escalationReason and createJob(ESCALATION) after delivery
- recoverPendingResponse createJob(ESCALATION) for stale delivery claims
- schedulePendingResponseRecovery createJob(AI_RESPONSE) takeover path
- priorAiResponse confirmed-delivery, required-escalation, stale, owner, and generic gates
- responseState PENDING, DELIVERED, and ESCALATED plus responseError recovery markers
Call-site enumeration: responseState is read and written only by queue/src/handlers/ai-response.ts; schema generation exports the Prisma enum through db/src/index.ts export *; migration creation is the only database definition.
Call-site enumeration: this index is created only by the 20260811193000 migration and represented by Message @@unique([ticketId, responseKey]); queue/src/handlers/ai-response.ts is its sole conflict consumer.
Call-site inventory:
- poll calls reclaimStaleJobs before both claim paths.
- claimJobsByType and claimAndProcessJobs call processJob.
- processJob writes missing-handler failure and successful completion.
- unsuccessful results and thrown/time-out errors call handleFailure for retry or dead-letter writes.
- handler reportProgress calls updateJobProgress for progress writes.
Integration repair: Prisma MessageResponseState enum narrows the Message create payload; this call site is the only inferred object whose PENDING literal widened to string.
Call sites audited:
- handleMessage dispatch: apps/teams-bot/src/index.ts -> handlers/message.ts
- TeamsAdapter.parseInboundEvent: handlers/message.ts; teams-adapter.test.ts; shared platforms-adapters.test.ts
- orphan reply path: InboundHandler.handleReply -> handleNewTicket(answer=false) -> Teams isNewTicket acknowledgment branch
- policy tests: message.test.ts; teams-adapter.test.ts; inbound-handler.test.ts
Call-site enumeration: the prior-response gate is the sole consumer; legacy BOT rows remain fallback evidence of an answer, while response lifecycle recovery reads the PRIMARY_AI_RESPONSE row. Tests cover both legacy-only skip and legacy-before-primary recovery.
Call-site enumeration: start and timer callbacks now enter through runPoll; stop waits the tracked poll before inspecting activeJobs; every poll reschedule uses schedulePoll and is suppressed once running is false.
Call sites/state paths audited:
- Postmark new-email ticket.create with nested opening Message
- MessageID written to Ticket.sourceId and idempotency reads by EMAIL/sourceId
- Ticket source/sourceId index and new email-only unique database index
- out-of-transaction createJob(AI_RESPONSE), replaced by tx.job.create
- Postmark create, reply, orphan, attachment, error, concurrency, and rollback tests
Call-site inventory: schedulePendingResponseRecovery is entered only by the PRIMARY/PENDING same-owner guard; recoverPendingResponse is entered only by the explicit pendingResponseRecovery payload plus message/job ownership; recoverRequiredEscalation is entered only by the PRIMARY/PENDING ESCALATION_REQUIRED marker gate. All escalation creation paths, including the initial post-delivery path, now use the same transactional Message CAS plus Job insert. Message response-state writes remain at primary creation, confirmed-delivery repair, escalation marker/error recording, delivered completion, and the transactional escalation transition.
Prisma migrate deploy executes this multi-statement migration atomically; PostgreSQL rejects CREATE INDEX CONCURRENTLY in that context. Retain the ordinary unique index so the one-response invariant is actually installed.
The recovery path moved from `message.update` by id to `message.updateMany`
guarded on `responseKey` and `responseState: 'PENDING'`, so the write only
lands if the row is still the pending primary response. The test still
asserted the unguarded `update`, which would keep passing if the guard were
dropped — the whole point of the change.

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: one blocker, one note to correct, rest as follow-ups

Core change is right and the reasoning in the diff is unusually careful — the self-correction about the guard being a backstop rather than the enforcement is the correct read. Two things I'd want before merge, then this is a go.

Verified locally: PR-touched packages/outpost suites pass (130 tests). App-package suites don't load in my worktree on uninstalled deps (@octokit/rest, @hubspot/api-client) — unrelated to the diff, and CI is green on them.


Blocking: the Teams orphan path posts a bot card into a thread we were never part of

The new orphan branch at platforms/inbound.ts:270 routes into handleNewTicket, which returns isNewTicket: true unconditionally (inbound.ts:226). apps/teams-bot/src/handlers/message.ts:67 branches on exactly that flag to post buildTicketCreatedCard.

So a mid-thread "thanks, that worked!" — isThreadStart = !activity.replyToId, no ticket found — now produces a "🎫 We've got your question" card posted into the conversation. And because the monitored-channel gate at message.ts:53 runs only if (message.isThreadStart), it fires in unmonitored channels too.

That's the unwanted-bot-chatter class this PR exists to remove, reintroduced on the platform the PR itself identifies as the most reachable for the orphan path. The new orphan test drives InboundHandler directly, so it can't see this.

Suggested fix, small: distinguish the orphan case in InboundResult (isOrphanedReply: true, or return isNewTicket: false) and gate both the ack card and the additionalInfo write on it. Worth moving the channel gate above the handle() call in the same pass.

Also before merge: the #169 deferral rationale is wrong

The PR body says #169's inbound-uniqueness constraint "also closes the guard's check-then-write race." It doesn't. A unique key on the inbound platform key prevents duplicate tickets; it does not serialize two AI_RESPONSE jobs against the same ticket. That needs a different constraint — a unique partial index on Message(ticketId) where type='BOT' and isAiGenerated, or an atomic conditional update.

Fine to leave the race deferred. But as written, the note points #169 at a fix that won't close it, so whoever picks up #169 will believe this is handled.

Worth recording while you're editing that section: the race is reachable without any duplicate inbound event. worker.ts:374 runWithTimeout is a Promise.race and does not cancel the handler; AI_RESPONSE has a 120s timeout (apps/worker/src/index.ts:74) over a deliberately slow pipeline. On timeout, attempt 1 keeps running while attempt 2 starts ~1s later, reads messages before attempt 1's step-5 message.create, passes the guard, and both post.


Follow-ups — not blocking

The guard flips a deliberately-failed job to COMPLETED on retry. ai-response.ts:450 returns success: false for "undelivered and escalation not enqueued" so an operator sees it. But worker.ts:413 marks the job PENDING; attempt 2 hits the guard (attempt 1's BOT row exists), returns success: true, and worker.ts:341 writes status: COMPLETED, progress: 100. The error column is left stale but the status reads green. The fail-loud return from 508e904 is neutralized by the guard added in the same PR. Fix would be to verify a delivered response (e.g. externalCommentId present) before the skip path declares success.

Postmark still answers an unresolvable reply. At postmark/route.ts:59, a present-but-unresolvable MailboxHash (deleted ticket, stale hash) falls through to the new-ticket branch and enqueues at line 139. Same for any reply arriving without the plus-address — sourceId: body.MessageID is unique per email and there's no In-Reply-To/References fallback, so a plain reply is always "new" and always answered. Minimum fix: when ticketIdFromHash was present but unresolvable, treat it as an orphaned reply.

Orphaned-reply behavior is Teams-only. inbound.ts:255 states the principle — dropping a customer's words is worse than an oddly-titled ticket — but Discord returns at message-create.ts:54, GitHub at issue-comment.ts:33, and Slack pre-filters at message.ts:56. All three still drop. Either adopt it everywhere or scope it to Teams explicitly in the comment.

Ack UX now diverges. Discord and Slack post nothing; Teams still posts a card. The rationale for removing the Discord/Slack posts applies equally to Teams. Intentional either way, just worth a line.

shadow-mode.ts:76 writes sourceId: thread.id inline, bypassing buildTicketSourceId, in a file this PR touches and against source-id.ts's own "don't inline" contract. Identity for Discord today, so harmless — but it's the drift surface the helper exists to close. Same on the read side in discord-bot/src/lib/tickets.ts:10 and teams-bot/src/lib/tickets.ts:10.

Nit: literal escapes left in comments at slack-bot/src/events/message.ts:61 and discord-bot/src/events/thread-create.ts:88, where the rest of the codebase uses a real em dash.


On the tests

Genuinely strong, and the red-green discipline shows. Two things I want to call out as the right calls: self-testing the visibleCardStrings walker is exactly the response a mutation-proven-dead guard deserves, and mocking at the generateSupportResponse seam is correct when the assertion is "no model call at all."

Gaps all trace to the findings above: nothing drives handleMessage on the Teams orphan path, nothing covers guard-skip-after-failure, nothing covers the timeout double-post.

On the skipped confirmation round — given nine of twelve findings were defects in the first commit, I'd read that as evidence the round earns its cost rather than evidence human review substitutes for it. Not a merge condition; the fixes here are individually verified.

Review of #170 caught a regression this branch introduced. The orphaned-reply
fallback creates a ticket with `{ answer: false }` so no AI response is
enqueued, but it routes through `handleNewTicket`, which returns
`isNewTicket: true` unconditionally. The Teams handler branches on exactly that
flag to write a conversationReference and post an acknowledgement card.

So a mid-thread "thanks, that worked!" -- Teams sets isThreadStart from the
absence of replyToId, and no ticket matches -- made the bot post a card into a
conversation it was never part of. That is the unwanted-chatter class this
branch exists to remove, reintroduced on the platform most reachable for the
orphan path.

`InboundResult` now carries `isOrphanedReply`, set true only on that fallback,
and `handleNewTicket` takes `{ answer, orphanedReply }` as two independent
decisions rather than inferring one from the other. The Teams handler gates
both the card and the additionalInfo write on it. `isNewTicket` stays true: a
ticket really was created, and other consumers may reasonably care.

The existing handleMessage orphan test asserted the bug -- it expected
sendActivity to have been called once -- so it would have kept this green
forever. It now asserts the ticket and message are still persisted while
createJob, sendActivity and ticket.update are not called. Verified red twice:
reverting the gate fails it, and gating only the card while leaving the
additionalInfo write ungated also fails it, so each half is independently
covered.

Call-site enumeration for isOrphanedReply -- 1 consumer changed, 7 cleared:

  - apps/teams-bot/src/handlers/message.ts -- CHANGED. The only consumer with a
    reporter-visible side effect on the new-ticket branch.
  - apps/slack-bot/src/events/message.ts -- cleared: pre-filters replies whose
    thread has no ticket, so it cannot reach the fallback.
  - apps/discord-bot/src/events/message-create.ts -- cleared: same pre-filter.
  - apps/github-app/src/webhooks/issue-comment.ts -- cleared: returns when no
    ticket matches, and never uses InboundHandler.
  - apps/github-app/src/webhooks/issues-opened.ts and discussion-created.ts --
    cleared: isThreadStart is always true, and neither has a reporter-visible
    side effect on that branch.
  - apps/discord-bot/src/events/thread-create.ts -- cleared: no ack post since
    this branch removed it.
  - apps/web -- cleared: never uses InboundHandler; Postmark has its own path
    and already implements the orphan rule locally.

Two further review findings, same shape as the above:

Route the last three inlined `Ticket.sourceId` sites through
`buildTicketSourceId` -- shadow-mode.ts on the write side, discord-bot and
teams-bot lib/tickets.ts on the read side. Identity for both platforms today,
so this fixes no live bug; it closes the drift surface the helper exists for,
which already produced one write/read mismatch on this branch. Readers return
early on an unaddressable key instead of querying `sourceId: null`, which would
match unrelated keyless rows. Adds tickets.test.ts for discord-bot, which had
none, and stops shadow-mode.test.ts stubbing the whole shared module -- the
helper was mocked away, so no test there could have caught a drift.

Correct comment prose that overstated what the code does. The literal —
escape was in four files, not the two previously fixed -- github-app's
issues-opened and discussion-created carried it too, plus a mangled ellipsis.
The orphaned-reply rationale in inbound.ts read as a universal principle, but
Discord, GitHub and Slack all pre-filter before reaching it and Postmark
implements it locally, so Teams is the only caller that arrives there; the
comment now says so. The Teams ack card's divergence from Discord and Slack,
which post nothing, is recorded as deliberate rather than left to look like an
oversight.

Not addressed here, both belonging to the email-threading follow-up:
postmark/route.ts inlines the EMAIL key as body.MessageID in three places, and
a plain reply with no plus-address is still treated as new and answered,
because sourceId is the per-email MessageID and there is no In-Reply-To or
References fallback.

@NathanTarbert NathanTarbert left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this was a genuinely useful review, and the blocker was a real regression. Fixed in 0bc5900.

Blocking: the Teams orphan card

You were right, and it was worse than you could see from the diff: the existing handleMessage orphan test asserted the bug. It expected sendActivity to have been called once, so it would have kept this green indefinitely.

Fixed as you suggested. InboundResult now carries isOrphanedReply, set true only on the fallback, and handleNewTicket takes { answer, orphanedReply } as two independent decisions rather than inferring one from the other. The Teams handler gates both the ack card and the additionalInfo write on it. I kept isNewTicket: true per your preference — a ticket really was created.

The replacement test asserts the ticket and message still persist while createJob, sendActivity and ticket.update are all not called. Verified red twice: reverting the gate fails it, and gating only the card while leaving the additionalInfo write ungated also fails it, so each half is independently covered.

One correction on the channel gate: that half was already fixed in 91bc0cd — it now runs above the handle() call and covers replies. I verified before touching it rather than fixing it twice.

The #169 rationale — you're right about the reasoning, and it's since been implemented

Your point is correct and worth stating plainly: a unique key on the inbound platform key prevents duplicate tickets and does nothing to serialize two AI_RESPONSE jobs against one ticket. My original note pointed #169 at a fix that wouldn't have closed the race, and someone picking it up would have believed it was handled.

The reason I'm not changing code here is that the mechanism you describe as the real fix — "a unique partial index on Message ... or an atomic conditional update" — is now in the branch, both halves:

  • Message.responseKey with a unique index on (ticketId, responseKey) (20260811193000_add_message_response_key).
  • ai-response.ts:564 claims that slot via message.create before any platform post-back; the loser catches a narrowly-scoped P2002 (isPrimaryAiResponseConflict, deliberately narrow so an unrelated P2002 still fails the job) and returns at :575 without posting.
  • Every subsequent state transition is a compare-and-set via updateMany guarded on responseKey + responseState, never a blind update by id.

So the timeout scenario you traced — runWithTimeout being a Promise.race that doesn't cancel, attempt 1 still running while attempt 2 passes the history gate — now ends with attempt 2 colliding on the create and returning. Only one post. Thanks for spelling out that path; it's the clearest statement of the race anyone has written down, and it's in the rescoped #169 as the reason the constraint had to live on Message rather than Ticket.

The PR body and #169 were both corrected before this reply. #169 is now scoped to what's genuinely left: no generic (source, sourceId) uniqueness (only EMAIL has an index), no find-first-or-upsert in handleNewTicket, and no dedupe on GitHub comment.id, Slack thread starts, or Discord ThreadCreate.

Follow-ups

Guard flipping a failed job to COMPLETED — already closed, by 5a9c98d. An undelivered response with no durable escalation writes an ESCALATION_REQUIRED marker into responseError (ai-response.ts:732), and the retry checks requiredEscalationReason at :387 — before the generic already-answered skip at :414 — routing into recoverRequiredEscalation instead of returning success. Your diagnosis of the shape was right; it landed between your read and this reply.

Postmark unresolvable hash — already closed, by 4d93868. isOrphanedReply = ticketIdFromHash !== null (route.ts:~127) treats a present-but-unresolvable hash as an orphaned reply and keeps it out of the AI queue.

Your second sub-point there is still live and is the largest remaining hole in the rule: a plain reply with no plus-address is always "new" and always answered, since sourceId is the per-email MessageID and there's no In-Reply-To/References fallback. Left with the email-threading follow-up rather than folded in here — happy to pull it forward if you'd rather it block.

Orphaned-reply behavior is Teams-only — documented rather than universalized, as you offered. Verified all three of your pre-filter citations, and found two more facts worth recording: the GitHub comment webhook never uses InboundHandler at all, and Postmark honors the principle but implements it locally. Teams is the only caller that reaches the branch, and the comment now says exactly that.

Ack UX divergence — recorded as deliberate at the Teams ack site, so it doesn't read as an oversight. Worth flagging that a first draft of that comment justified it by the card's "buttons" — buildTicketCreatedCard emits body only, no actions. Caught and rewritten before commit, but it's the same class of error as the displayId-resolves-clicks comment you'd expect me to have learned from.

shadow-mode.ts bypassing buildTicketSourceId — fixed, along with the read sides in discord-bot/src/lib/tickets.ts and teams-bot/src/lib/tickets.ts. Readers return early on an unaddressable key rather than querying sourceId: null, which would match unrelated keyless rows. Two things surfaced while doing it: discord-bot had no coverage for lib/tickets.ts at all, and shadow-mode.test.ts was stubbing the entire @copilotkit/outpost/shared module — so buildTicketSourceId was mocked away and no test there could have caught a drift. Both fixed.

One residual of the same class, not fixed: postmark/route.ts inlines the EMAIL key as body.MessageID at :133, :157 and :194.

Nit — the literal escape was in four files, not two. github-app's issues-opened.ts and discussion-created.ts had it as well, plus a mangled ellipsis. My earlier pass fixed two of six occurrences and reported it closed.

On the confirmation round

Fair, and I'd rather take the correction than defend the shortcut. You're right that nine-of-twelve findings being defects in the first commit argues for the round, not that human review substitutes for it — and this review is a second data point, since it found a regression that twelve fix agents and I all read past.

Current state: typecheck 10/10, 1881 tests pass (11 new), CI green.

One answer per ticket now holds on email the way it holds everywhere else: a
new email opens a ticket and gets exactly one AI answer, a reply gets none.

Reply detection used only the plus-address MailboxHash. A reply that arrives
without one -- the normal case when someone replies from a client that drops the
plus-address, or replies to a plain From address -- fell through to the
new-ticket branch. That split one conversation across two tickets and spent an
AI answer on a mid-conversation message.

Detection now falls back to the In-Reply-To and References headers, which were
already carried on PostmarkInboundPayload and read by nothing. Candidate IDs
from both headers resolve against Ticket.sourceId (the MessageID of the email
that opened the ticket) and Message.attachments.postmarkMessageId (a
mid-thread message).

References matters more than In-Reply-To here. When a customer replies to
something we sent, In-Reply-To names a Message-ID we never stored --
EmailPostmarkAdapter.postResponse throws `not yet implemented`, so no outbound
mail has ever been sent and no outbound ID recorded. References accumulates the
whole chain and therefore still contains the customer's own original MessageID,
which is what Ticket.sourceId holds.

Behaviour:

  - reply, resolvable      -> append, reopen if reopensOnCustomerReply, no job
  - reply, unresolvable    -> file a ticket so the words are not lost, no job
  - not a reply            -> new ticket, one job (unchanged)

MailboxHash stays the primary path when present: it is an exact ticket
reference and cheaper than header matching.

Supporting change, without which the mid-thread half of the lookup is dead
code: postmarkMessageId is now persisted on every message, not only on messages
that carried a file attachment. Additive -- nothing read the field before.
Existing rows are not backfilled, so a reply into a pre-existing thread still
resolves via Ticket.sourceId but not via a mid-thread message.

Normalisation is tested directly rather than incidentally. Postmark's MessageID
field is bare while header values are angle-bracketed and space-separated, so a
single stray bracket would make every lookup miss and turn every reply back
into a new ticket -- the exact bug being fixed, with a green suite.

23 new tests, 9 mutations each verified red then green: In-Reply-To matching
sourceId; References matching where In-Reply-To does not; a mid-thread
attachments match; headers resolving to nothing; and a genuinely new email
still being answered, so this is not a blanket mute.

Ticket.sourceId semantics for EMAIL and the Ticket_email_sourceId_key partial
unique index are untouched.

Reported, not fixed:

  - EmailPostmarkAdapter.parseInboundEvent still detects replies by In-Reply-To
    alone -- the same defect on the deferred adapter path.
  - Outbound Message-IDs are still never persisted, because postResponse is
    unimplemented.
  - No expression index backs the JSON-path lookup on
    attachments.postmarkMessageId; fine at current volume, a sequential scan
    later.
@NathanTarbert

Copy link
Copy Markdown
Collaborator Author

Following up on one thing from your review that I'd left filed rather than fixed — the product owner asked for it to be pulled forward, so it's now in the PR at 76fb78d.

Your second Postmark sub-point is fixed

You wrote:

Same for any reply arriving without the plus-address — sourceId: body.MessageID is unique per email and there's no In-Reply-To/References fallback, so a plain reply is always "new" and always answered.

That was the largest remaining hole in the rule, and it's closed. Detection now falls back to In-Reply-To and References — both already typed on PostmarkInboundPayload.Headers and read by nothing — resolving candidate IDs against Ticket.sourceId and Message.attachments.postmarkMessageId.

Behaviour is now:

Incoming Result
New email new ticket, one AI job
Reply, MailboxHash present appended, no job (already worked)
Reply, resolved via headers appended, reopened if reopensOnCustomerReply, no job
Reply, resolves to nothing ticket filed so the words aren't lost, no job

MailboxHash stays the primary path when present — exact ticket reference, cheaper than header matching.

Two things worth surfacing because they shaped the fix:

References is doing the real work, not In-Reply-To. When a customer replies to something we sent, In-Reply-To names a Message-ID we never stored — postResponse throws, so no outbound mail has ever been sent and no outbound ID recorded. References accumulates the whole chain and therefore still contains the customer's own original Message-ID, which is exactly what Ticket.sourceId holds.

One supporting change beyond detection: postmarkMessageId was only persisted on messages that carried a file attachment, so the mid-thread half of the lookup would have had an empty store and been dead code that tests could still fake. Now persisted on every message. Additive; existing rows aren't backfilled, so replies into pre-existing threads resolve via Ticket.sourceId but not via a mid-thread message.

23 new tests, 9 mutations each verified RED then GREEN — including In-Reply-To matching sourceId, References matching where In-Reply-To doesn't, a mid-thread attachments match, headers resolving to nothing, and a genuinely new email still being answered so this isn't a blanket mute. Bracket/whitespace normalisation gets its own direct test: Postmark's MessageID is bare while header values are angle-bracketed and space-separated, and one stray bracket would make every lookup miss and turn every reply back into a new ticket — the exact bug, with a green suite.

Ticket.sourceId semantics for EMAIL and the Ticket_email_sourceId_key index are untouched.

Deliberately still out of scope

While in there I found that EMAIL is registered as a delivery platform but cannot deliverEmailPostmarkAdapter.postResponse throws not yet implemented, so an email ticket generates an answer, stores it, fails to post, and (since this PR's delivery-failure escalation) pulls in a human for a channel that was never able to deliver.

Filed as #171 rather than fixed here, because the fix is a product decision: unregister EMAIL, or implement sending. The second starts mailing customers automatically and needs threading headers, persisted outbound IDs, and a verified sending domain — not something to decide inside this PR, and not easily undone.

Also captured there, both yours in origin: EmailPostmarkAdapter.parseInboundEvent still detects replies by In-Reply-To alone, so the deferred adapter refactor inherits the original bug; and the email door bypassing InboundHandler means no team-member gate, no userId link, no truncate. Plus one of mine: no expression index behind the new JSON-path lookup on attachments.postmarkMessageId — fine at current volume, a sequential scan later.

The remaining inline-key residual I mentioned last time is unchanged: postmark/route.ts still inlines the EMAIL key as body.MessageID at :133, :157 and :194.

Current state: 1902 tests pass, typecheck 10/10, CI green at 76fb78d. Your blocker and every follow-up is either fixed or filed with a reason.

@NathanTarbert

Copy link
Copy Markdown
Collaborator Author

Splitting this per the plan in the split brief.

The reviewed core — the actual bot-interrupting fix plus the ticket-ID removal — is now #172: 43 files, 2,133 insertions, zero database changes, based on e02f620 (the reviewed 13 commits) with 91bc0cd and 0bc5900 lifted on top. That ships independently.

This PR stays open and will be reduced to the remainder: (ticketId, responseKey) uniqueness, the response state machine, job claim-token fencing, stale-job reclaim, shutdown drain, Postmark idempotency and header threading, plus four migrations. That half has not had a review pass.

Rationale for the split: the safe fix was trapped behind the risky one, and rolling back the job-queue work would have rolled back the bot fix with it. The two must not be recombined — independent rollback is the point.

@NathanTarbert

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #187.

Per the split brief: the customer-facing fix merged as #172, and the remaining infrastructure work has been rebuilt on current main as #187.

Why closed rather than reduced in place. This PR's diff still showed the pre-hotfix state, its title described work that has already merged, and CHANGES_REQUESTED was anchored to commits that no longer exist. Reducing it would have meant a force-push that detached every review anchor anyway.

Rebuilt rather than rebased, and that turned out to matter: this branch predates the #180 hotfix, so replaying it would have deleted the SystemConfig repair migration (−93), the schema-drift guard in start.sh (−210), and its CI gate (−257). #187 is built on main with all six hotfix files verified byte-identical.

@jerelvelarde — both blockers you raised are fixed and shipped in #172 (0bc5900 for the Teams orphan ack card; the concurrency race closed by the (ticketId, responseKey) unique claim, which is in #187). The three findings from the brief's technical section are also fixed in #187: escalationEnqueued being write-only, the In-Reply-To/References thread hijack, and responseError carrying control state. The review state moved because the scope changed, not because anything you found persists.

#187 has not had a full review round — that was the reason for the split.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants