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
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.
When a user starts the onboarding flow:
The root cause: Intent classification happens every turn, with no awareness that a guided process is in progress.
Adopt a two-tier intent architecture:
Represents the user’s active engagement with a structured process:
High-level state is:
Represents the micro-intent within a single message:
Turn-level intent is:
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)
[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.”
We considered improving the intent classifier to detect “user is continuing onboarding” but rejected this because:
The PortfolioOnboardingManager uses a module-level singleton to persist session state across HTTP requests. This pattern:
conversation_handler._get_onboarding_components())Warning: Creating new PortfolioOnboardingManager() instances loses state. Always use the singleton accessor.
| 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. |
The pattern has been generalized into the ProcessRegistry system:
New Files:
services/process/registry.py: ProcessRegistry singleton, GuidedProcess protocol, ProcessType enumservices/process/adapters.py: OnboardingProcessAdapter, StandupProcessAdapterservices/process/__init__.py: Public API exportsModified Files:
services/intent/intent_service.py: Added unified _check_active_guided_process() using ProcessRegistryservices/container/initialization.py: Registers default processes at startupKey Concepts:
Test Coverage:
tests/unit/services/process/test_registry.py: 33 tests (18 original + 15 new for escape/suspend/discovery)tests/unit/services/process/test_adapters.py: 14 tests for adaptersProblem: 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:
ESCAPE_COMMANDS frozenset in registry.py):
handler.suspend() then returns ProcessCheckResult.escaped_from()check_active() with isinstance(updated_at, datetime) guardOFFERED state for onboarding (non-active from registry perspective)offer_onboarding() creates session in OFFERED statehandle_offer_response() transitions on explicit acceptance_check_pending_onboarding_offer() in intent_service.py catches responseshas_suspended_session() on GuidedProcess protocolcheck_suspended_processes() on ProcessRegistry (dumb aggregator)PortfolioOnboardingState and StandupConversationStatecheck_active() perspectiveExtended GuidedProcess Protocol:
suspend(user_id, session_id) — transition to SUSPENDED statehas_suspended_session(user_id) → Optional[SuspendedInfo] — discover suspended sessionsModified Files:
services/process/registry.py: ESCAPE_COMMANDS, SuspendedInfo, escaped_from(), _is_escape_command(), check_suspended_processes()services/process/adapters.py: Timeout, suspend(), has_suspended_session() on both adaptersservices/shared_types.py: OFFERED + SUSPENDED statesservices/onboarding/portfolio_manager.py: VALID_TRANSITIONS updatedservices/onboarding/portfolio_handler.py: offer_onboarding(), handle_offer_response()services/standup/conversation_manager.py: VALID_TRANSITIONS updatedservices/conversation/conversation_handler.py: Offer-first flow, suspended re-entryservices/intent/intent_service.py: _check_pending_onboarding_offer()services/onboarding/portfolio_manager.py: PortfolioOnboardingManagerservices/standup/conversation_manager.py: StandupConversationManagerservices/conversation/conversation_handler.py: Module-level singletonstests/e2e/test_onboarding_http_e2e.py: True E2E testsThis ADR aligns with:
User-defined guided processes (analogous to Claude skills) could extend this architecture.
| 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 |