# 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.*
