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:

  1. Tenant reports boiler issue via SMS
  2. AI collects details, creates issue, escalates to human
  3. Tenant sends: “Actually I also have problems with the electrics they turn off randomly”
  4. Message is stored but never processed — AI is off, no new issue created

Root constraints:

  1. conversations.issue_id is UNIQUE — one conversation can only link to one issue
  2. escalatedToHuman = true sets handledByAI = false — binary kill switch on all AI processing
  3. conversationHasIssue() guard prevents create_issue tool from firing twice

Design Goals

  1. A single SMS/WhatsApp thread can produce multiple issues
  2. Escalation of one issue does not silence the AI for the whole conversation
  3. The AI stays active as a router/classifier even when individual issues are human-handled
  4. Tenants can check status of existing issues, report new ones, and ask general questions — all in one thread
  5. Property managers see all issues clearly linked to the conversation with individual states
  6. Scales to 3,000+ properties without prompt bloat

Research Summary

SourceKey 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 FinAI stays as router even when humans engaged — detects new topics and re-engages
HFTP 202568% of tenants cite poor communication as top reason for negative reviews
HMO studiesBundled reports are the norm — “while I’ve got you…” is standard tenant behaviour
NN/g chatbot UXAcknowledge 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:

  1. Quick-classify first (regex patterns for greetings, status keywords like “update”, “when”, “how long”)
  2. 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 intent

d) 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)

  1. Run migration to create conversation_issues table
  2. Backfill from existing conversations.issue_id
  3. Update Prisma schema, pnpm db:pull && pnpm prisma generate
  4. Add read functions (getConversationIssues, etc.)
  5. No behaviour change yet — existing code still uses conversations.issue_id

Phase 2: Dual-write (write to both old and new)

  1. createIssueFromConversation writes to both conversations.issue_id AND conversation_issues
  2. updateConversationStatus writes to both places
  3. All reads migrate to conversation_issues table
  4. Deploy, monitor for correctness

Phase 3: New behaviour (classifier + multi-issue)

  1. Add classifier.ts — intent classification on every message
  2. Update process.ts routing logic
  3. Remove !hasIssue guard from create_issue tool
  4. Update escalation to be per-issue
  5. Update system prompt for multi-issue awareness
  6. Deploy behind feature flag (MULTI_ISSUE_CONVERSATIONS=true)

Phase 4: Clean up

  1. Drop conversations.issue_id column (or keep as primary_issue_id for quick lookups)
  2. Drop conversations.gathering_state (now per-issue in junction table)
  3. Remove feature flag, make default behaviour
  4. 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

RiskImpactMitigation
Classifier misidentifies follow-up as new issueDuplicate issues createdConfidence threshold — if < 0.7, ask tenant: “Is this about [existing issue] or a new problem?”
Prompt bloat with many open issuesSlower/costlier LLM callsCap at 5 most recent issues in prompt; summarise older ones
Media sent after issue B created but meant for issue AWrong attachmentAsk tenant which issue the photo is for if multiple are active
Staff confused by multi-issue conversationsDashboard UX frictionClear per-issue status in conversation view; train staff
Migration breaks existing conversationsData lossDual-write phase ensures no data loss; rollback plan ready
Haiku classifier adds latencySlower responseQuick-classify regex first; Haiku only when ambiguous (~100ms)

Estimated Effort

PhaseScopeFiles
Phase 1: SchemaMigration, Prisma, read functions4 files
Phase 2: Dual-writeIssue creation, conversation state3 files
Phase 3: New behaviourClassifier, routing, prompts, status checks5 new + 2 modified
Phase 4: Clean upDrop legacy columns, remove flag2 files
DashboardConversation detail, issue chips2-3 components
TestsUnit + integration + E2E3-4 test files

Open Questions

  1. 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.
  2. Max issues per conversation — should we cap it? 5? 10? Unbounded?
  3. Media disambiguation — when a tenant sends a photo and has 2 active issues, which one gets it? Default to most recent active issue? Ask?
  4. Staff reply routing — if staff replies in the dashboard, which issue does it relate to? Need per-issue reply context.
  5. Conversation summary — current summary field is single-issue. Need multi-issue summary generation.