Plan: Multi-Issue Conversation Threading
Status: Draft Created: 2026-04-14 Linear: TBD Severity: Bug / Architecture gap
Problem Statement
The tenant engine has a 1:1 relationship between conversations and issues (conversations.issue_id UNIQUE constraint). Once an issue is created and escalated, the AI shuts down entirely (handledByAI = false). If the tenant reports a second issue in the same SMS thread, it falls into a void — the AI ignores it and the human agent is focused on the first escalation.
Reproduction:
- Tenant reports boiler issue via SMS
- AI collects details, creates issue, escalates to human
- Tenant sends: “Actually I also have problems with the electrics they turn off randomly”
- Message is stored but never processed — AI is off, no new issue created
Root constraints:
conversations.issue_idis UNIQUE — one conversation can only link to one issueescalatedToHuman = truesetshandledByAI = false— binary kill switch on all AI processingconversationHasIssue()guard preventscreate_issuetool from firing twice
Design Goals
- A single SMS/WhatsApp thread can produce multiple issues
- Escalation of one issue does not silence the AI for the whole conversation
- The AI stays active as a router/classifier even when individual issues are human-handled
- Tenants can check status of existing issues, report new ones, and ask general questions — all in one thread
- Property managers see all issues clearly linked to the conversation with individual states
- Scales to 3,000+ properties without prompt bloat
Research Summary
| Source | Key Finding |
|---|---|
| Rasa CALM (dialogue stack) | Push/pop conversation flows — new topic pushes onto stack, interrupted flow resumes after |
| ”Lost in Conversation” (arXiv 2025) | LLMs suffer ~30% degradation when locked into single intent — exactly our bug |
| askporter (competitor) | 4.2/5 CSAT by keeping conversations open; 13% self-resolved through continued AI |
| Intercom Fin | AI stays as router even when humans engaged — detects new topics and re-engages |
| HFTP 2025 | 68% of tenants cite poor communication as top reason for negative reviews |
| HMO studies | Bundled reports are the norm — “while I’ve got you…” is standard tenant behaviour |
| NN/g chatbot UX | Acknowledge existing issue, signal topic switch, immediately start collecting |
Architecture
Current Flow (broken)
INBOUND MESSAGE
↓
escalatedToHuman? ──yes──→ DEAD END (message stored, nobody acts)
↓ no
hasExistingIssue? ──yes──→ create_issue tool disabled, AI can only chat
↓ no
Normal orchestration → create_issue → escalate → AI OFF
Proposed Flow
INBOUND MESSAGE
↓
ALWAYS run intent classifier (even post-escalation)
↓
┌──────────────┬───────────────┬────────────────┬──────────────┐
│ NEW_ISSUE │ FOLLOW_UP(id) │ STATUS_CHECK │ GENERAL_Q │
│ │ │ │ │
│ AI starts │ Route to │ AI responds │ AI responds │
│ collecting │ human handler │ with status │ directly │
│ details │ for that issue│ from DB │ │
│ │ │ │ │
│ Creates new │ Notify staff │ No issue │ No issue │
│ issue when │ of follow-up │ created │ created │
│ ready │ message │ │ │
└──────────────┴───────────────┴────────────────┴──────────────┘
Conversation State Machine v2
CONVERSATION (session — always ACTIVE until manually closed)
│
├── aiRouter: ALWAYS ON (classifies every inbound message)
│
├── Issue Thread #1 (boiler)
│ state: IDLE → COLLECTING → CREATED → ESCALATED
│ handledBy: AI → AI → AI → HUMAN
│
├── Issue Thread #2 (electrics)
│ state: IDLE → COLLECTING → CREATED
│ handledBy: AI → AI → AI
│
└── General (no issue)
AI responds to questions, status checks, etc.
Data Model Changes
Phase 1: New junction table + remove unique constraint
Migration: 0XX_multi_issue_conversations.sql
-- ============================================================
-- Multi-Issue Conversation Threading
-- ============================================================
-- 1. New junction table: conversation_issues
-- Replaces the 1:1 conversations.issue_id relationship
-- Each row tracks per-issue state within a conversation
CREATE TABLE conversation_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
-- Per-issue state (moved from conversation level)
gathering_state TEXT NOT NULL DEFAULT 'IDLE'
CHECK (gathering_state IN ('IDLE', 'COLLECTING', 'AWAITING_PHOTO', 'CREATED', 'ESCALATED')),
handled_by TEXT NOT NULL DEFAULT 'AI'
CHECK (handled_by IN ('AI', 'HUMAN')),
-- Ordering — which issue is "active" in the conversation
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- A conversation can only link to each issue once
UNIQUE (conversation_id, issue_id)
);
-- Indexes
CREATE INDEX idx_conversation_issues_conversation
ON conversation_issues(conversation_id);
CREATE INDEX idx_conversation_issues_issue
ON conversation_issues(issue_id);
CREATE INDEX idx_conversation_issues_active
ON conversation_issues(conversation_id)
WHERE is_active = true;
-- RLS
ALTER TABLE conversation_issues ENABLE ROW LEVEL SECURITY;
CREATE POLICY conversation_issues_org_access ON conversation_issues
FOR ALL
USING (
EXISTS (
SELECT 1 FROM conversations c
WHERE c.id = conversation_issues.conversation_id
AND c.organisation_id = auth.jwt() ->> 'organisation_id'::text
)
);
-- 2. Migrate existing data
-- Move current conversations.issue_id into the junction table
INSERT INTO conversation_issues (conversation_id, issue_id, gathering_state, handled_by, is_active)
SELECT
c.id,
c.issue_id,
CASE
WHEN c.gathering_state::text = 'ISSUE_CREATED' THEN 'CREATED'
WHEN c.gathering_state::text = 'GATHERING_DETAILS' THEN 'COLLECTING'
WHEN c.gathering_state::text = 'AWAITING_PHOTO' THEN 'AWAITING_PHOTO'
ELSE 'IDLE'
END,
CASE WHEN c.escalated_to_human THEN 'HUMAN' ELSE 'AI' END,
true
FROM conversations c
WHERE c.issue_id IS NOT NULL;
-- 3. Add active_issue_id (nullable, tracks which issue thread the AI is working on)
ALTER TABLE conversations
ADD COLUMN active_issue_id UUID REFERENCES issues(id);
-- Backfill active_issue_id from current issue_id
UPDATE conversations
SET active_issue_id = issue_id
WHERE issue_id IS NOT NULL;
-- 4. Replace binary escalation with nuanced state
-- handledByAI stays for backwards compat but meaning changes:
-- true = AI router is active (should almost always be true)
-- false = entire conversation handed to human (emergency, explicit request)
--
-- Per-issue escalation tracked in conversation_issues.handled_by
-- 5. Drop the unique constraint on issue_id (allow multiple issues)
-- Keep the column for now as a "primary issue" pointer (backwards compat)
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_issue_id_key;
-- 6. Add conversation-level AI router flag (replaces binary escalation logic)
ALTER TABLE conversations
ADD COLUMN ai_router_active BOOLEAN NOT NULL DEFAULT true;
-- Backfill: if conversation was escalated, AI router should still be active
-- Only disable for truly dead conversations
UPDATE conversations
SET ai_router_active = true
WHERE status != 'ARCHIVED';
-- 7. Extend GatheringState enum with new values
-- (conversation-level gathering_state becomes a legacy field,
-- per-issue state lives in conversation_issues)Phase 2: Prisma schema changes
// NEW MODEL
model ConversationIssue {
id String @id @default(uuid()) @db.Uuid
conversationId String @map("conversation_id") @db.Uuid
issueId String @map("issue_id") @db.Uuid
gatheringState String @default("IDLE") @map("gathering_state")
handledBy String @default("AI") @map("handled_by")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
@@unique([conversationId, issueId])
@@map("conversation_issues")
}
// UPDATED: Conversation model
model Conversation {
// ... existing fields ...
issueId String? @unique @map("issue_id") @db.Uuid // REMOVE @unique
activeIssueId String? @map("active_issue_id") @db.Uuid // NEW
aiRouterActive Boolean @default(true) @map("ai_router_active") // NEW
// ... existing relations ...
conversationIssues ConversationIssue[] // NEW
activeIssue Issue? @relation("ActiveIssue", fields: [activeIssueId], references: [id]) // NEW
}
// UPDATED: Issue model
model Issue {
// ... existing fields ...
conversationIssues ConversationIssue[] // NEW
activeInConversations Conversation[] @relation("ActiveIssue") // NEW
}Tenant Engine Changes
1. New file: lib/tenant-engine/classifier.ts
Lightweight intent classifier that runs on EVERY inbound message, including post-escalation.
type MessageIntent =
| { type: 'NEW_ISSUE'; confidence: number }
| { type: 'FOLLOW_UP'; issueId: string; confidence: number }
| { type: 'STATUS_CHECK'; issueId?: string; confidence: number }
| { type: 'GENERAL_QUESTION'; confidence: number }
| { type: 'FRUSTRATION'; confidence: number }
| { type: 'GREETING'; confidence: number }
interface ClassifierContext {
message: string
conversationId: string
openIssues: Array<{
id: string
description: string
category: string
status: string
handledBy: 'AI' | 'HUMAN'
}>
recentMessages: Array<{ role: string; content: string }>
}
export async function classifyIntent(
ctx: ClassifierContext
): Promise<MessageIntent>Implementation approach:
- Quick-classify first (regex patterns for greetings, status keywords like “update”, “when”, “how long”)
- If ambiguous OR there are open issues, call Haiku with a tiny prompt (~200 tokens):
You are classifying a tenant's SMS message.
Open issues for this tenant:
{{#each openIssues}}
- Issue #{{id}}: {{description}} ({{category}}, {{status}})
{{/each}}
Tenant's message: "{{message}}"
Classify as one of:
- NEW_ISSUE: Tenant is reporting a different problem
- FOLLOW_UP: Tenant is adding info to issue #<id>
- STATUS_CHECK: Tenant is asking about progress
- GENERAL_QUESTION: Not about a specific issue
- FRUSTRATION: Tenant is upset about response time or quality
Respond with JSON: { "type": "...", "issueId": "..." (if applicable), "confidence": 0.0-1.0 }
Cost: ~150 input tokens + 30 output tokens per classification = negligible ($0.00003/message on Haiku).
2. Changes to lib/tenant-engine/process.ts
a) Replace the escalation kill switch
// BEFORE (line ~295 in process.ts)
if (conversation.escalatedToHuman) {
// Store message but don't process — DEAD END
return { status: 'escalated', message: 'Conversation escalated to human' }
}
// AFTER
if (!conversation.aiRouterActive) {
// Only skip if AI router explicitly disabled (e.g. emergency full-handoff)
return { status: 'ai_disabled', message: 'AI router disabled for this conversation' }
}
// Always classify, even if some issues are human-handled
const openIssues = await getConversationIssues(conversation.id)
const intent = await classifyIntent({
message: text,
conversationId: conversation.id,
openIssues,
recentMessages: await getRecentMessages(conversation.id, 6),
})b) Route based on classification
switch (intent.type) {
case 'NEW_ISSUE':
// Deactivate current issue thread, start new collection
await deactivateCurrentIssue(conversation.id)
return await orchestrateWithTools({
...params,
activeIssueId: null, // No linked issue — create_issue tool available
gatheringState: 'IDLE',
})
case 'FOLLOW_UP':
const issue = openIssues.find(i => i.id === intent.issueId)
if (issue?.handledBy === 'HUMAN') {
// Route to human — store message, notify staff
await notifyStaffOfFollowUp(conversation.id, intent.issueId, text)
return await sendReply(conversation.id,
`Your message about the ${issue.category.toLowerCase()} issue has been passed to our team. They'll get back to you shortly.`
)
}
// AI-handled follow-up — continue orchestration with issue context
return await orchestrateWithTools({
...params,
activeIssueId: intent.issueId,
gatheringState: issue?.gatheringState ?? 'IDLE',
})
case 'STATUS_CHECK':
return await handleStatusCheck(conversation, intent.issueId, openIssues)
case 'GENERAL_QUESTION':
return await orchestrateWithTools({
...params,
activeIssueId: null,
gatheringState: 'IDLE',
})
case 'FRUSTRATION':
// Escalate entire conversation to human
await escalateConversation(conversation.id, 'Tenant expressing frustration')
return await sendReply(conversation.id,
`I understand this is frustrating. A member of our staff will now take over and get back to you as soon as possible.`
)
}c) Update create_issue tool gating
// BEFORE
const canCreateIssue = identityConfirmed && propertyId && !hasIssue
// AFTER
const canCreateIssue = identityConfirmed && propertyId
// No more !hasIssue guard — multiple issues allowed
// The classifier already determined this is a NEW_ISSUE intentd) Update create_issue handler
async function handleToolCreateIssue(
conversation: ConversationContext,
tenant: IdentifiedTenant | null,
toolInput: CreateIssueInput
): Promise<ProcessingResult> {
// ... existing validation ...
const issue = await createIssueFromConversation({ ... })
// NEW: Create junction record instead of setting conversation.issue_id
await createConversationIssue({
conversationId: conversation.id,
issueId: issue.id,
gatheringState: 'CREATED',
handledBy: 'AI',
isActive: true,
})
// NEW: Set as active issue on conversation
await updateConversation(conversation.id, {
activeIssueId: issue.id,
})
// ... existing notification logic ...
}e) Update escalate tool handler
// BEFORE
await updateConversationStatus(conversation.id, 'ESCALATED', {
escalatedToHuman: true,
})
// AFTER — escalate the ISSUE, not the conversation
await updateConversationIssue(conversation.id, activeIssueId, {
handledBy: 'HUMAN',
gatheringState: 'ESCALATED',
})
// Deactivate this issue thread (AI will pick up new topics)
await deactivateCurrentIssue(conversation.id)
// Conversation stays ACTIVE, AI router stays on
// Only set conversation-level ESCALATED if ALL issues are human-handled
const allIssues = await getConversationIssues(conversation.id)
const allHuman = allIssues.every(i => i.handledBy === 'HUMAN')
if (allHuman) {
await updateConversationStatus(conversation.id, 'ESCALATED')
}3. Changes to lib/tenant-engine/conversation.ts
New functions:
// Get all issues linked to a conversation
export async function getConversationIssues(
conversationId: string
): Promise<ConversationIssueWithDetails[]>
// Create a new conversation-issue link
export async function createConversationIssue(
params: CreateConversationIssueParams
): Promise<ConversationIssue>
// Update per-issue state
export async function updateConversationIssue(
conversationId: string,
issueId: string,
data: Partial<ConversationIssueUpdate>
): Promise<void>
// Deactivate current issue (set is_active = false)
export async function deactivateCurrentIssue(
conversationId: string
): Promise<void>
// Notify staff of a follow-up message on a human-handled issue
export async function notifyStaffOfFollowUp(
conversationId: string,
issueId: string,
message: string
): Promise<void>4. Changes to buildOrchestratorPrompt()
Update the system prompt to be multi-issue aware:
function buildOrchestratorPrompt(params: OrchestratorParams): string {
const lines: string[] = []
// ... existing identity/property context ...
// NEW: Multi-issue context
if (params.openIssues.length > 0) {
lines.push('')
lines.push('OPEN ISSUES FOR THIS TENANT:')
for (const issue of params.openIssues) {
const handler = issue.handledBy === 'HUMAN' ? '(being handled by staff)' : '(you are handling)'
lines.push(`- ${issue.category}: "${issue.description}" [${issue.status}] ${handler}`)
}
lines.push('')
lines.push('If the tenant is reporting a NEW problem (different from above), collect details and create a new issue.')
lines.push('If they are adding info to an existing issue, acknowledge and update accordingly.')
}
// NEW: Active issue context
if (params.activeIssueId) {
lines.push('')
lines.push(`You are currently collecting details for: ${params.activeIssueDescription}`)
} else if (params.openIssues.length > 0) {
lines.push('')
lines.push('No specific issue is being discussed right now. The tenant may report a new issue or ask about existing ones.')
}
// ... existing gathering state notes ...
}5. New file: lib/tenant-engine/status-check.ts
Handle “any update?” type messages:
export async function handleStatusCheck(
conversation: ConversationContext,
issueId: string | undefined,
openIssues: ConversationIssueWithDetails[]
): Promise<ProcessingResult> {
if (issueId) {
const issue = openIssues.find(i => i.id === issueId)
if (!issue) {
return sendReply(conversation.id,
"I couldn't find that issue. Could you tell me more about what you'd like an update on?")
}
return sendReply(conversation.id, formatStatusUpdate(issue))
}
// No specific issue — summarise all
if (openIssues.length === 1) {
return sendReply(conversation.id, formatStatusUpdate(openIssues[0]))
}
const summary = openIssues
.map((i, idx) => `${idx + 1}. ${i.category}: ${i.status}`)
.join('\n')
return sendReply(conversation.id,
`Here's where things stand:\n${summary}\n\nWhich one would you like more details on?`)
}UX: Tenant-Facing Message Templates
New issue detected mid-conversation
Your [boiler] issue is being looked at by our team.
Now about the electrics turning off — I'd like to log
this separately so it gets resolved too.
Which rooms are affected, and how often does it happen?
Status check response (single issue)
Your [boiler] issue is currently [in progress].
A [plumber] has been assigned and is scheduled for
[Tuesday 15th]. We'll update you when it's resolved.
Status check response (multiple issues)
Here's where things stand:
1. Boiler: Plumber assigned, visit scheduled
2. Electrics: Logged, awaiting review
Reply 1 or 2 for more details on either.
Follow-up routed to human
Your message about the [boiler] issue has been passed
to our team. They'll get back to you shortly.
Frustration detected
I understand this is frustrating. A member of our
staff will now take over and get back to you as
soon as possible.
Dashboard Changes
Conversation detail view
The conversation panel in the dashboard should show:
- Linked issues as chips/badges below the conversation header (not just one)
- Each chip shows: category icon + short description + status badge + handler (AI/Human)
- Clicking a chip navigates to the issue detail
- Staff can manually link/unlink issues from conversations
Issue detail view
- Show “Reported via conversation” link with timestamp
- If multiple issues from same conversation, show sibling issues
Migration Strategy
Phase 1: Schema + backwards-compatible reads (no behaviour change)
- Run migration to create
conversation_issuestable - Backfill from existing
conversations.issue_id - Update Prisma schema,
pnpm db:pull && pnpm prisma generate - Add read functions (
getConversationIssues, etc.) - No behaviour change yet — existing code still uses
conversations.issue_id
Phase 2: Dual-write (write to both old and new)
createIssueFromConversationwrites to bothconversations.issue_idANDconversation_issuesupdateConversationStatuswrites to both places- All reads migrate to
conversation_issuestable - Deploy, monitor for correctness
Phase 3: New behaviour (classifier + multi-issue)
- Add
classifier.ts— intent classification on every message - Update
process.tsrouting logic - Remove
!hasIssueguard fromcreate_issuetool - Update escalation to be per-issue
- Update system prompt for multi-issue awareness
- Deploy behind feature flag (
MULTI_ISSUE_CONVERSATIONS=true)
Phase 4: Clean up
- Drop
conversations.issue_idcolumn (or keep asprimary_issue_idfor quick lookups) - Drop
conversations.gathering_state(now per-issue in junction table) - Remove feature flag, make default behaviour
- Update dashboard UI for multi-issue display
Testing Plan
Unit tests
-
classifyIntent()correctly identifies NEW_ISSUE vs FOLLOW_UP vs STATUS_CHECK -
classifyIntent()handles edge cases: vague messages, mixed-intent, single word -
getConversationIssues()returns correct per-issue state -
createConversationIssue()enforces unique constraint -
deactivateCurrentIssue()only deactivates, doesn’t delete -
handleStatusCheck()formats single/multiple issue summaries -
buildOrchestratorPrompt()includes multi-issue context correctly
Integration tests
- Full flow: report issue A → escalate → report issue B → both issues exist
- Follow-up on human-handled issue routes to staff notification
- Status check returns correct info from DB
- Frustration detection triggers full escalation
- Media attachment goes to correct issue (active one)
- Emergency detection still works (bypasses classifier)
- Identity flow still works (classifier only runs for CONFIRMED/ACTIVE)
E2E tests (SMS simulator)
- Tenant reports boiler → AI collects details → issue created
- Same thread: tenant reports electrics → AI collects details → second issue created
- Same thread: tenant asks “any update on the boiler?” → gets status
- Same thread: tenant says “this is ridiculous nobody has come” → escalated to human
- Staff replies in dashboard → tenant receives response
- New conversation after 24h timeout → fresh start, but old issues visible
Risks & Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Classifier misidentifies follow-up as new issue | Duplicate issues created | Confidence threshold — if < 0.7, ask tenant: “Is this about [existing issue] or a new problem?” |
| Prompt bloat with many open issues | Slower/costlier LLM calls | Cap at 5 most recent issues in prompt; summarise older ones |
| Media sent after issue B created but meant for issue A | Wrong attachment | Ask tenant which issue the photo is for if multiple are active |
| Staff confused by multi-issue conversations | Dashboard UX friction | Clear per-issue status in conversation view; train staff |
| Migration breaks existing conversations | Data loss | Dual-write phase ensures no data loss; rollback plan ready |
| Haiku classifier adds latency | Slower response | Quick-classify regex first; Haiku only when ambiguous (~100ms) |
Estimated Effort
| Phase | Scope | Files |
|---|---|---|
| Phase 1: Schema | Migration, Prisma, read functions | 4 files |
| Phase 2: Dual-write | Issue creation, conversation state | 3 files |
| Phase 3: New behaviour | Classifier, routing, prompts, status checks | 5 new + 2 modified |
| Phase 4: Clean up | Drop legacy columns, remove flag | 2 files |
| Dashboard | Conversation detail, issue chips | 2-3 components |
| Tests | Unit + integration + E2E | 3-4 test files |
Open Questions
- 24h conversation timeout — should a new message after 24h create a new conversation, or reopen the old one? Currently creates new. With multi-issue, reopening might be better.
- Max issues per conversation — should we cap it? 5? 10? Unbounded?
- Media disambiguation — when a tenant sends a photo and has 2 active issues, which one gets it? Default to most recent active issue? Ask?
- Staff reply routing — if staff replies in the dashboard, which issue does it relate to? Need per-issue reply context.
- Conversation summary — current
summaryfield is single-issue. Need multi-issue summary generation.