Files
syndarix/docs/adrs/ADR-011-issue-synchronization.md
Felipe Cardoso 88cf4e0abc feat: Update to production model stack and fix remaining inconsistencies
## Model Stack Updates (User's Actual Models)

Updated all documentation to reflect production models:
- Claude Opus 4.5 (primary reasoning)
- GPT 5.1 Codex max (code generation specialist)
- Gemini 3 Pro/Flash (multimodal, fast inference)
- Qwen3-235B (cost-effective, self-hostable)
- DeepSeek V3.2 (self-hosted, open weights)

### Files Updated:
- ADR-004: Full model groups, failover chains, cost tables
- ADR-007: Code example with correct model identifiers
- ADR-012: Cost tracking with new model prices
- ARCHITECTURE.md: Model groups, failover diagram
- IMPLEMENTATION_ROADMAP.md: External services list

## Architecture Diagram Updates

- Added LangGraph Runtime to orchestration layer
- Added technology labels (Type-Instance, transitions)

## Self-Hostability Table Expanded

Added entries for:
- LangGraph (MIT)
- transitions (MIT)
- DeepSeek V3.2 (MIT)
- Qwen3-235B (Apache 2.0)

## Metric Alignments

- Response time: Split into API (<200ms) and Agent (<10s/<60s)
- Cost per project: Adjusted to $100/sprint for Opus 4.5 pricing
- Added concurrent projects (10+) and agents (50+) metrics

## Infrastructure Updates

- Celery workers: 4-8 instances (was 2-4) across 4 queues
- MCP servers: Clarified Phase 2 + Phase 5 deployment
- Sync interval: Clarified 60s fallback + 15min reconciliation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:35:51 +01:00

6.7 KiB

ADR-011: Issue Synchronization Architecture

Status: Accepted Date: 2025-12-29 Deciders: Architecture Team Related Spikes: SPIKE-009


Context

Syndarix must synchronize issues bi-directionally with external trackers (Gitea, GitHub, GitLab). Agents create and update issues internally, which must reflect in external systems. External changes must flow back to Syndarix.

Decision Drivers

  • Real-time: Changes visible within seconds
  • Consistency: Eventual consistency acceptable
  • Conflict Resolution: Clear rules when edits conflict
  • Multi-provider: Support Gitea (primary), GitHub, GitLab
  • Reliability: Handle network failures gracefully

Considered Options

Option 1: Polling Only

Periodically fetch all issues from external trackers.

Pros: Simple, reliable Cons: High latency (minutes), API quota waste

Option 2: Webhooks Only

Rely solely on external webhooks.

Pros: Real-time Cons: May miss events during outages

Option 3: Webhook-First + Polling Fallback (Selected)

Primary: webhooks for real-time. Secondary: polling for reconciliation.

Pros: Real-time with reliability Cons: Slightly more complex

Decision

Adopt webhook-first architecture with polling fallback and Last-Writer-Wins (LWW) conflict resolution.

External trackers are the source of truth. Syndarix maintains local mirrors for unified agent access.

Implementation

Sync Architecture

External Trackers (Gitea/GitHub/GitLab)
              │
    ┌─────────┴─────────┐
    │     Webhooks      │ (real-time)
    └─────────┬─────────┘
              │
    ┌─────────┴─────────┐
    │   Webhook Handler │ → Redis Queue → Sync Engine
    └───────────────────┘
              │
    ┌─────────┴─────────┐
    │  Polling Worker   │ (fallback: 60s, full reconciliation: 15 min)
    └───────────────────┘
              │
    ┌─────────┴─────────┐
    │    PostgreSQL     │
    │ (issues, sync_log)│
    └───────────────────┘

Provider Abstraction

class IssueProvider(ABC):
    """Abstract interface for issue tracker providers."""

    @abstractmethod
    async def get_issue(self, issue_id: str) -> ExternalIssue: ...

    @abstractmethod
    async def list_issues(self, repo: str, since: datetime) -> list[ExternalIssue]: ...

    @abstractmethod
    async def create_issue(self, repo: str, issue: IssueCreate) -> ExternalIssue: ...

    @abstractmethod
    async def update_issue(self, issue_id: str, issue: IssueUpdate) -> ExternalIssue: ...

    @abstractmethod
    def parse_webhook(self, payload: dict) -> WebhookEvent: ...

# Provider implementations
class GiteaProvider(IssueProvider): ...
class GitHubProvider(IssueProvider): ...
class GitLabProvider(IssueProvider): ...

Conflict Resolution

Scenario Resolution
Same field, different timestamps Last-Writer-Wins (LWW)
Same field, concurrent edits Mark conflict, notify user
Different fields modified Merge both changes
Delete vs Update Delete wins (configurable)

Database Schema

CREATE TABLE issues (
    id UUID PRIMARY KEY,
    project_id UUID NOT NULL,
    external_id VARCHAR(100),
    external_provider VARCHAR(50),  -- 'gitea', 'github', 'gitlab'
    external_url VARCHAR(500),

    -- Canonical fields
    title VARCHAR(500) NOT NULL,
    body TEXT,
    state VARCHAR(50) NOT NULL,
    labels JSONB DEFAULT '[]',
    assignees JSONB DEFAULT '[]',

    -- Sync metadata
    external_updated_at TIMESTAMPTZ,
    local_updated_at TIMESTAMPTZ,
    sync_status VARCHAR(50) DEFAULT 'synced',
    sync_conflict JSONB,

    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

CREATE TABLE issue_sync_log (
    id UUID PRIMARY KEY,
    issue_id UUID NOT NULL,
    direction VARCHAR(10) NOT NULL,  -- 'inbound', 'outbound'
    action VARCHAR(50) NOT NULL,     -- 'create', 'update', 'delete'
    before_state JSONB,
    after_state JSONB,
    provider VARCHAR(50) NOT NULL,
    sync_time TIMESTAMPTZ NOT NULL
);

Webhook Handler

@router.post("/webhooks/{provider}")
async def handle_webhook(
    provider: str,
    request: Request,
    background_tasks: BackgroundTasks
):
    """Handle incoming webhooks from issue trackers."""
    payload = await request.json()

    # Validate signature
    provider_impl = get_provider(provider)
    if not provider_impl.verify_signature(request.headers, payload):
        raise HTTPException(401, "Invalid signature")

    # Queue for processing (deduplication in Redis)
    event = provider_impl.parse_webhook(payload)
    await redis.xadd(
        f"sync:webhooks:{provider}",
        {"event": event.json()},
        id="*",
        maxlen=10000
    )

    return {"status": "queued"}

Outbox Pattern for Outbound Sync

class SyncOutbox:
    """Reliable outbound sync with retry."""

    async def queue_update(self, issue_id: str, changes: dict):
        await db.execute("""
            INSERT INTO sync_outbox (issue_id, changes, status, created_at)
            VALUES ($1, $2, 'pending', NOW())
        """, issue_id, json.dumps(changes))

@celery_app.task
def process_sync_outbox():
    """Process pending outbound syncs with exponential backoff."""
    pending = db.query("SELECT * FROM sync_outbox WHERE status = 'pending' LIMIT 100")

    for item in pending:
        try:
            issue = db.get_issue(item.issue_id)
            provider = get_provider(issue.external_provider)
            await provider.update_issue(issue.external_id, item.changes)

            item.status = 'completed'
        except Exception as e:
            item.retry_count += 1
            item.next_retry = datetime.now() + timedelta(minutes=2 ** item.retry_count)
            if item.retry_count > 5:
                item.status = 'failed'

Consequences

Positive

  • Real-time sync via webhooks
  • Reliable reconciliation via polling
  • Clear conflict resolution rules
  • Provider-agnostic design

Negative

  • Eventual consistency (brief inconsistency windows)
  • Webhook infrastructure required

Mitigation

  • Manual refresh available in UI
  • Conflict notification alerts users

Compliance

This decision aligns with:

  • FR-401: Issue hierarchy
  • FR-402: External issue synchronization
  • FR-403: Issue CRUD operations
  • NFR-201: Horizontal scalability (multi-provider architecture)

This ADR establishes the issue synchronization architecture for Syndarix.