--- summary: "Channel agnostic session binding architecture and iteration 1 delivery scope" read_when: - Refactoring channel-agnostic session routing and bindings - Investigating duplicate, stale, or missing session delivery across channels owner: "onutc" status: "in-progress" last_updated: "2026-02-21" title: "Session Binding Channel Agnostic Plan" --- # Session Binding Channel Agnostic Plan ## Overview This document defines the long term channel agnostic session binding model and the concrete scope for the next implementation iteration. Goal: - make subagent bound session routing a core capability - keep channel specific behavior in adapters - avoid regressions in normal Discord behavior ## Why this exists Current behavior mixes: - completion content policy - destination routing policy - Discord specific details This caused edge cases such as: - duplicate main and thread delivery under concurrent runs - stale token usage on reused binding managers - missing activity accounting for webhook sends ## Iteration 1 scope This iteration is intentionally limited. ### 1. Add channel agnostic core interfaces Add core types and service interfaces for bindings and routing. Proposed core types: ```ts export type BindingTargetKind = "subagent" | "session"; export type BindingStatus = "active" | "ending" | "ended"; export type ConversationRef = { channel: string; accountId: string; conversationId: string; parentConversationId?: string; }; export type SessionBindingRecord = { bindingId: string; targetSessionKey: string; targetKind: BindingTargetKind; conversation: ConversationRef; status: BindingStatus; boundAt: number; expiresAt?: number; metadata?: Record; }; ``` Core service contract: ```ts export interface SessionBindingService { bind(input: { targetSessionKey: string; targetKind: BindingTargetKind; conversation: ConversationRef; metadata?: Record; ttlMs?: number; }): Promise; listBySession(targetSessionKey: string): SessionBindingRecord[]; resolveByConversation(ref: ConversationRef): SessionBindingRecord | null; touch(bindingId: string, at?: number): void; unbind(input: { bindingId?: string; targetSessionKey?: string; reason: string; }): Promise; } ``` ### 2. Add one core delivery router for subagent completions Add a single destination resolution path for completion events. Router contract: ```ts export interface BoundDeliveryRouter { resolveDestination(input: { eventKind: "task_completion"; targetSessionKey: string; requester?: ConversationRef; failClosed: boolean; }): { binding: SessionBindingRecord | null; mode: "bound" | "fallback"; reason: string; }; } ``` For this iteration: - only `task_completion` is routed through this new path - existing paths for other event kinds remain as-is ### 3. Keep Discord as adapter Discord remains the first adapter implementation. Adapter responsibilities: - create/reuse thread conversations - send bound messages via webhook or channel send - validate thread state (archived/deleted) - map adapter metadata (webhook identity, thread ids) ### 4. Fix currently known correctness issues Required in this iteration: - refresh token usage when reusing existing thread binding manager - record outbound activity for webhook based Discord sends - stop implicit main channel fallback when a bound thread destination is selected for session mode completion ### 5. Preserve current runtime safety defaults No behavior change for users with thread bound spawn disabled. Defaults stay: - `channels.discord.threadBindings.spawnSubagentSessions = false` Result: - normal Discord users stay on current behavior - new core path affects only bound session completion routing where enabled ## Not in iteration 1 Explicitly deferred: - ACP binding targets (`targetKind: "acp"`) - new channel adapters beyond Discord - global replacement of all delivery paths (`spawn_ack`, future `subagent_message`) - protocol level changes - store migration/versioning redesign for all binding persistence Notes on ACP: - interface design keeps room for ACP - ACP implementation is not started in this iteration ## Routing invariants These invariants are mandatory for iteration 1. - destination selection and content generation are separate steps - if session mode completion resolves to an active bound destination, delivery must target that destination - no hidden reroute from bound destination to main channel - fallback behavior must be explicit and observable ## Compatibility and rollout Compatibility target: - no regression for users with thread bound spawning off - no change to non-Discord channels in this iteration Rollout: 1. Land interfaces and router behind current feature gates. 2. Route Discord completion mode bound deliveries through router. 3. Keep legacy path for non-bound flows. 4. Verify with targeted tests and canary runtime logs. ## Tests required in iteration 1 Unit and integration coverage required: - manager token rotation uses latest token after manager reuse - webhook sends update channel activity timestamps - two active bound sessions in same requester channel do not duplicate to main channel - completion for bound session mode run resolves to thread destination only - disabled spawn flag keeps legacy behavior unchanged ## Proposed implementation files Core: - `src/infra/outbound/session-binding-service.ts` (new) - `src/infra/outbound/bound-delivery-router.ts` (new) - `src/agents/subagent-announce.ts` (completion destination resolution integration) Discord adapter and runtime: - `src/discord/monitor/thread-bindings.manager.ts` - `src/discord/monitor/reply-delivery.ts` - `src/discord/send.outbound.ts` Tests: - `src/discord/monitor/provider*.test.ts` - `src/discord/monitor/reply-delivery.test.ts` - `src/agents/subagent-announce.format.test.ts` ## Done criteria for iteration 1 - core interfaces exist and are wired for completion routing - correctness fixes above are merged with tests - no main and thread duplicate completion delivery in session mode bound runs - no behavior change for disabled bound spawn deployments - ACP remains explicitly deferred