# ADR-014: Client Approval Flow Architecture **Status:** Accepted **Date:** 2025-12-29 **Deciders:** Architecture Team **Related Spikes:** SPIKE-012 --- ## Context Syndarix supports configurable autonomy levels. Depending on the level, agents may require client approval before proceeding with certain actions. We need a flexible approval system that: - Respects autonomy level configuration - Provides clear approval UX - Handles timeouts gracefully - Supports mobile-friendly approvals ## Decision Drivers - **Configurability:** Per-project autonomy settings - **Usability:** Easy approve/reject with context - **Reliability:** Approvals must not be lost - **Flexibility:** Support batch and individual approvals - **Responsiveness:** Real-time notifications ## Decision **Implement checkpoint-based approval system** with: - Queue-based approval management - Confidence-aware routing - Multi-channel notifications (SSE, email, mobile push) - Configurable timeout and escalation policies ## Implementation ### Autonomy Levels | Level | Description | Approval Required | |-------|-------------|-------------------| | **FULL_CONTROL** | Approve every significant action | All actions | | **MILESTONE** | Approve at sprint boundaries | Sprint start/end, major decisions | | **AUTONOMOUS** | Only critical decisions | Budget, production, architecture | ### Approval Categories ```python class ApprovalCategory(str, Enum): CRITICAL = "critical" # Always require approval MILESTONE = "milestone" # MILESTONE and FULL_CONTROL ROUTINE = "routine" # FULL_CONTROL only UNCERTAINTY = "uncertainty" # Low confidence decisions EXPERTISE = "expertise" # Agent requests human input ``` ### Approval Matrix | Action | FULL_CONTROL | MILESTONE | AUTONOMOUS | |--------|--------------|-----------|------------| | Requirements approval | Required | Required | Required | | Architecture decisions | Required | Required | Required | | Sprint start | Required | Required | Auto | | Story implementation | Required | Auto | Auto | | PR merge | Required | Auto | Auto | | Sprint completion | Required | Required | Auto | | Budget threshold exceeded | Required | Required | Required | | Production deployment | Required | Required | Required | ### Database Schema ```sql CREATE TABLE approval_requests ( id UUID PRIMARY KEY, project_id UUID NOT NULL, -- What needs approval category VARCHAR(50) NOT NULL, action_type VARCHAR(100) NOT NULL, title VARCHAR(500) NOT NULL, description TEXT, context JSONB NOT NULL, -- Who requested requested_by_agent_id UUID, requested_at TIMESTAMPTZ NOT NULL, -- Status status VARCHAR(50) DEFAULT 'pending', -- pending, approved, rejected, expired decided_by_user_id UUID, decided_at TIMESTAMPTZ, decision_comment TEXT, -- Timeout handling expires_at TIMESTAMPTZ, escalation_policy JSONB, -- AI context confidence_score FLOAT, ai_recommendation VARCHAR(50), reasoning TEXT ); ``` ### Approval Service ```python class ApprovalService: async def request_approval( self, project_id: str, action_type: str, category: ApprovalCategory, context: dict, requested_by: str, confidence: float | None = None, ai_recommendation: str | None = None ) -> ApprovalRequest: """Create an approval request and notify stakeholders.""" project = await self.get_project(project_id) # Check if approval needed based on autonomy level if not self._needs_approval(project.autonomy_level, category): return ApprovalRequest(status="auto_approved") # Create request request = ApprovalRequest( project_id=project_id, category=category, action_type=action_type, context=context, requested_by_agent_id=requested_by, confidence_score=confidence, ai_recommendation=ai_recommendation, expires_at=datetime.utcnow() + self._get_timeout(category) ) await self.db.add(request) # Send notifications await self._notify_approvers(project, request) return request async def await_decision( self, request_id: str, timeout: timedelta = timedelta(hours=24) ) -> ApprovalDecision: """Wait for approval decision (used in workflows).""" deadline = datetime.utcnow() + timeout while datetime.utcnow() < deadline: request = await self.get_request(request_id) if request.status == "approved": return ApprovalDecision.APPROVED elif request.status == "rejected": return ApprovalDecision.REJECTED elif request.status == "expired": return await self._handle_expiration(request) await asyncio.sleep(5) return await self._handle_timeout(request) async def _handle_timeout(self, request: ApprovalRequest) -> ApprovalDecision: """Handle approval timeout based on escalation policy.""" policy = request.escalation_policy or {"action": "block"} if policy["action"] == "auto_approve": request.status = "auto_approved" return ApprovalDecision.APPROVED elif policy["action"] == "escalate": await self._escalate(request, policy["escalate_to"]) return await self.await_decision(request.id, timedelta(hours=24)) else: # block request.status = "expired" return ApprovalDecision.BLOCKED ``` ### Notification Channels ```python class ApprovalNotifier: async def notify(self, project: Project, request: ApprovalRequest): # SSE for real-time dashboard await self.event_bus.publish(f"project:{project.id}", { "type": "approval_required", "request_id": str(request.id), "title": request.title, "category": request.category }) # Email for async notification await self.email_service.send_approval_request( to=project.owner.email, request=request ) # Mobile push if configured if project.push_enabled: await self.push_service.send( user_id=project.owner_id, title="Approval Required", body=request.title, data={"request_id": str(request.id)} ) ``` ### Batch Approval UI For FULL_CONTROL mode with many routine approvals: ``` ┌─────────────────────────────────────────────────────────┐ │ APPROVAL QUEUE (12 pending) │ ├─────────────────────────────────────────────────────────┤ │ ☑ PR #45: Add user authentication [ROUTINE] 2h ago │ │ ☑ PR #46: Fix login validation [ROUTINE] 2h ago │ │ ☑ PR #47: Update dependencies [ROUTINE] 1h ago │ │ ☐ Sprint 4 Start [MILESTONE] 30m │ │ ☐ Production Deploy v1.2 [CRITICAL] 15m │ ├─────────────────────────────────────────────────────────┤ │ [Approve Selected (3)] [Reject Selected] [Review All] │ └─────────────────────────────────────────────────────────┘ ``` ### Decision Context Display ```python class ApprovalContextBuilder: def build_context(self, request: ApprovalRequest) -> ApprovalContext: """Build rich context for approval decision.""" return ApprovalContext( summary=request.title, description=request.description, # What the AI recommends ai_recommendation=request.ai_recommendation, confidence=request.confidence_score, reasoning=request.reasoning, # Impact assessment affected_files=request.context.get("files", []), estimated_impact=request.context.get("impact", "unknown"), # Agent info requesting_agent=self._get_agent_info(request.requested_by_agent_id), # Quick actions approve_url=f"/api/approvals/{request.id}/approve", reject_url=f"/api/approvals/{request.id}/reject" ) ``` ## Consequences ### Positive - Flexible autonomy levels support various client preferences - Real-time notifications ensure timely responses - Batch approval reduces friction in FULL_CONTROL mode - AI confidence routing escalates appropriately ### Negative - Approval latency can slow autonomous workflows - Complex state management for pending approvals ### Mitigation - Encourage MILESTONE mode for efficiency - Configurable timeouts with auto-approve options - Mobile notifications for quick responses ## Compliance This decision aligns with: - FR-203: Autonomy level configuration - FR-301-305: Workflow checkpoints (approval gates at workflow boundaries) - NFR-402: Fault tolerance (approval state persistence) --- *This ADR establishes the client approval flow architecture for Syndarix.*