ADR-049: Conversational State and Hierarchical Intent Architecture

Status: Accepted Date: 2026-01-09 Accepted: 2026-01-26 Issue: #490 FTUX-PORTFOLIO Implementation: #427 MUX-IMPLEMENT-CONVERSE-MODEL Author: Lead Developer (Claude Code Opus) Approver: PM (xian), PPM, Chief Architect

Context

During implementation of portfolio onboarding (Issue #490), we discovered a fundamental architectural gap: Piper lacked “conversational state” - the ability to maintain control of a guided conversation once it begins.

The Problem

When a user starts the onboarding flow:

  1. Turn 1 (Greeting): User says “Hello” → Piper correctly triggers onboarding prompt
  2. Turn 2 (Project Info): User says “My main project is called Piper Morgan” → BUG: Message gets re-classified as IDENTITY intent (because “Piper Morgan” matches identity patterns), returning the identity response instead of continuing onboarding

The root cause: Intent classification happens every turn, with no awareness that a guided process is in progress.

Observed User Experience Issues

Decision

Adopt a two-tier intent architecture:

Tier 1: High-Level Conversational State (Process-Level Intent)

Represents the user’s active engagement with a structured process:

High-level state is:

Tier 2: Turn-Level Intent (Message-Level Classification)

Represents the micro-intent within a single message:

Turn-level intent is:

Implementation Pattern

async def process_intent(self, message: str, user_id: str, session_id: str):
    # TIER 1: Check for active conversational state FIRST
    if user_id:
        active_process = await self._check_active_process(user_id, session_id)
        if active_process:
            # Route directly to process handler - bypass classification
            return await active_process.handle_turn(message)

    # TIER 2: No active process - perform normal classification
    classified_intent = await self._classify_message(message)

    # Classification may START a new process (e.g., greeting → onboarding)
    return await self._route_to_handler(classified_intent)

Process Priority Check Order

  1. Active onboarding session (user is setting up portfolio)
  2. Active standup session (user is doing daily check-in)
  3. Active planning session (user is in planning mode)
  4. Pending clarification (Piper asked a question)
  5. No active process → perform classification

State Transitions

[No Process] --(greeting + new user)--> [Onboarding Offered]
[Onboarding Offered] --(user accepts)--> [Onboarding Active]
[Onboarding Offered] --(user declines)--> [Onboarding Declined] --> [No Process]
[Onboarding Active] --(user confirms)--> [Onboarding Complete] --> [No Process]
[Onboarding Active] --(user declines)--> [Onboarding Declined] --> [No Process]
[Onboarding Active] --(escape command)--> [Onboarding Suspended]
[Onboarding Active] --(timeout 30min)--> [Onboarding Suspended]
[Onboarding Suspended] --(greeting re-entry)--> [Onboarding Active]
[Onboarding Suspended] --(user declines)--> [Onboarding Declined] --> [No Process]

Issue #888 key principle: “The session belongs to the user, not the workflow.”

Rationale

Why Process-Level Takes Priority

  1. User expectation: When I agree to do something, I expect continuity
  2. UX principle: Guided flows should feel guided, not interrupted
  3. Pattern precedent: Standup assistant (Epic #242) already works this way
  4. Technical simplicity: Single check at start vs. complex re-classification

Why Not Just “Better Classification”?

We considered improving the intent classifier to detect “user is continuing onboarding” but rejected this because:

  1. Semantic ambiguity: “My project is Piper Morgan” could legitimately be identity OR project info
  2. Fragile patterns: Any pattern-based approach would have false positives
  3. Wrong abstraction: The problem isn’t classification accuracy, it’s architectural flow
  4. LLM-dependent: Would require expensive LLM calls for context-aware classification

Singleton Manager Pattern

The PortfolioOnboardingManager uses a module-level singleton to persist session state across HTTP requests. This pattern:

Warning: Creating new PortfolioOnboardingManager() instances loses state. Always use the singleton accessor.

Consequences

Positive

Negative

Risks and Mitigations

Risk Mitigation
Memory growth from abandoned sessions cleanup_expired() runs on configurable interval (default 30 min)
Process “traps” user Issue #888: Escape commands (“stop”, “quit”, “cancel”, “nevermind”, “never mind”, “exit”) intercepted at registry level BEFORE handler routing; timeout auto-suspends (onboarding 30min, standup 15min); offer-first activation for onboarding (OFFERED state, not auto-active)
Classification never runs during process Deliberate - process handler interprets messages contextually
Testing complexity E2E tests validate real user flows (Pattern-045 compliance)
Suspended session confusion Issue #888: Registry discovers suspended sessions on greeting, offers resume. One suspended session per process type per user.

Implementation Notes

Generalized Architecture (January 2026)

The pattern has been generalized into the ProcessRegistry system:

New Files:

Modified Files:

Key Concepts:

Test Coverage:

Issue #888 Amendment: Escape, Timeout, Offer-First (March 2026)

Problem: The original architecture allowed guided processes to “trap” users. Onboarding auto-activated on greeting with no escape mechanism. Users who didn’t want onboarding had their messages hijacked by the process handler.

PPM Binding Direction: “The session belongs to the user, not the workflow.”

New Mechanisms:

  1. Escape Commands (ESCAPE_COMMANDS frozenset in registry.py):
    • “stop”, “quit”, “cancel”, “nevermind”, “never mind”, “exit”
    • Intercepted at registry level BEFORE handler routing
    • Triggers handler.suspend() then returns ProcessCheckResult.escaped_from()
    • Exact match on stripped+lowercased full message (not substring)
  2. Timeout Auto-Suspend (adapters.py):
    • Onboarding: 30 minutes inactive → auto-suspend
    • Standup: 15 minutes inactive → auto-suspend
    • Checked in check_active() with isinstance(updated_at, datetime) guard
  3. Offer-First Activation (portfolio_handler.py, conversation_handler.py):
    • New OFFERED state for onboarding (non-active from registry perspective)
    • offer_onboarding() creates session in OFFERED state
    • handle_offer_response() transitions on explicit acceptance
    • _check_pending_onboarding_offer() in intent_service.py catches responses
  4. Suspended Session Re-Entry (conversation_handler.py):
    • has_suspended_session() on GuidedProcess protocol
    • check_suspended_processes() on ProcessRegistry (dumb aggregator)
    • Greeting handler checks for suspended sessions, offers resume
  5. SUSPENDED State (shared_types.py):
    • Added to both PortfolioOnboardingState and StandupConversationState
    • Non-active from check_active() perspective
    • Preserves all captured data for resumption

Extended GuidedProcess Protocol:

Modified Files:

Original Files (MVP)

Pattern Compliance

This ADR aligns with:

Future Vision

User-defined guided processes (analogous to Claude skills) could extend this architecture.

Review History

Date Reviewer Decision
2026-01-09 PM (xian) Proposed
2026-01-26 PPM, Chief Architect Approved for MVP implementation
2026-01-26 PM (xian) Accepted - implemented in #427
2026-03-13 PPM, Chief Architect #888 amendment: escape commands, timeout, offer-first activation, suspended re-entry
2026-03-13 PM (xian) Approved and implemented in #888