Files
wifi-densepose/npm/packages/ruvbot/docs/adr/ADR-005-integration-layer.md
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

23 KiB

ADR-005: Integration Layer

Status: Accepted Date: 2026-01-27 Decision Makers: RuVector Architecture Team Technical Area: Integrations, External Services


Context and Problem Statement

RuvBot must integrate with external systems to:

  1. Receive messages from Slack, webhooks, and other channels
  2. Send notifications and responses back to users
  3. Connect to AI providers for LLM inference and embeddings
  4. Interact with external APIs for skill execution
  5. Provide webhooks for third-party integrations

The integration layer must be:

  • Extensible for new integration types
  • Resilient to external service failures
  • Secure with proper authentication and authorization
  • Observable with logging and metrics

Decision Drivers

Integration Requirements

Integration Priority Features Required
Slack Critical Events, commands, blocks, threads
REST Webhooks Critical Inbound/outbound, signatures
Anthropic Claude Critical Completions, streaming
OpenAI High Completions, embeddings
Custom LLMs Medium Provider abstraction
External APIs Medium HTTP client, retries

Reliability Requirements

Requirement Target
Webhook delivery success > 99%
Provider failover time < 1s
Message ordering Within session
Duplicate detection 100%

Decision Outcome

Adopt Adapter Pattern with Circuit Breaker

We implement the integration layer using:

  1. Adapter Pattern: Common interface for each integration type
  2. Circuit Breaker: Prevent cascade failures from external services
  3. Retry with Backoff: Handle transient failures
  4. Event-Driven: Decouple ingestion from processing
+-----------------------------------------------------------------------------+
|                           INTEGRATION LAYER                                  |
+-----------------------------------------------------------------------------+

                    +---------------------------+
                    |     Integration Gateway   |
                    |   (Protocol Normalization)|
                    +-------------+-------------+
                                  |
          +-----------------------+-----------------------+
          |                       |                       |
+---------v---------+   +---------v---------+   +---------v---------+
|  Slack Adapter    |   |  Webhook Adapter  |   | Provider Adapter  |
|-------------------|   |-------------------|   |-------------------|
| - Events API      |   | - Inbound routes  |   | - LLM clients     |
| - Commands        |   | - Outbound queue  |   | - Embeddings      |
| - Interactive     |   | - Signatures      |   | - Circuit breaker |
| - OAuth           |   | - Retries         |   | - Failover        |
+-------------------+   +-------------------+   +-------------------+
          |                       |                       |
          +-----------------------+-----------------------+
                                  |
                    +-------------v-------------+
                    |      Event Normalizer     |
                    | (Unified Message Format)  |
                    +-------------+-------------+
                                  |
                    +-------------v-------------+
                    |       Core Context        |
                    +---------------------------+

Slack Integration

Architecture

// Slack integration components
interface SlackIntegration {
  // Event handling
  events: SlackEventHandler;

  // Slash commands
  commands: SlackCommandHandler;

  // Interactive components (buttons, modals)
  interactive: SlackInteractiveHandler;

  // Block Kit builder
  blocks: BlockKitBuilder;

  // Web API client
  client: SlackWebClient;

  // OAuth flow
  oauth: SlackOAuthHandler;
}

// Event types we handle
type SlackEventType =
  | 'message'
  | 'app_mention'
  | 'reaction_added'
  | 'reaction_removed'
  | 'channel_created'
  | 'member_joined_channel'
  | 'file_shared'
  | 'app_home_opened';

// Normalized event structure
interface SlackIncomingEvent {
  type: SlackEventType;
  teamId: string;
  channelId: string;
  userId: string;
  text?: string;
  threadTs?: string;
  ts: string;
  raw: unknown;
}

Event Handler

// Slack event processing
class SlackEventHandler {
  private eventQueue: Queue<SlackIncomingEvent>;
  private deduplicator: EventDeduplicator;

  constructor(
    private config: SlackConfig,
    private sessionManager: SessionManager,
    private agent: Agent
  ) {
    this.eventQueue = new Queue('slack-events');
    this.deduplicator = new EventDeduplicator({
      ttl: 300000, // 5 minutes
      keyFn: (e) => `${e.teamId}:${e.channelId}:${e.ts}`,
    });
  }

  // Express middleware for Slack events
  middleware(): RequestHandler {
    return async (req, res) => {
      // Verify Slack signature
      if (!this.verifySignature(req)) {
        return res.status(401).send('Invalid signature');
      }

      const body = req.body;

      // Handle URL verification challenge
      if (body.type === 'url_verification') {
        return res.json({ challenge: body.challenge });
      }

      // Acknowledge immediately (Slack 3s timeout)
      res.status(200).send();

      // Process event asynchronously
      await this.handleEvent(body.event);
    };
  }

  private async handleEvent(rawEvent: unknown): Promise<void> {
    const event = this.normalizeEvent(rawEvent);

    // Deduplicate (Slack may retry)
    if (await this.deduplicator.isDuplicate(event)) {
      this.logger.debug('Duplicate event ignored', { event });
      return;
    }

    // Filter events we care about
    if (!this.shouldProcess(event)) {
      return;
    }

    // Map to tenant context
    const tenant = await this.resolveTenant(event.teamId);
    if (!tenant) {
      this.logger.warn('Unknown Slack team', { teamId: event.teamId });
      return;
    }

    // Enqueue for processing
    await this.eventQueue.add('process', {
      event,
      tenant,
      receivedAt: Date.now(),
    });
  }

  private shouldProcess(event: SlackIncomingEvent): boolean {
    // Skip bot messages
    if (event.raw?.bot_id) return false;

    // Only process certain event types
    return ['message', 'app_mention'].includes(event.type);
  }

  private verifySignature(req: Request): boolean {
    const timestamp = req.headers['x-slack-request-timestamp'] as string;
    const signature = req.headers['x-slack-signature'] as string;

    // Prevent replay attacks (5 minute window)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp)) > 300) {
      return false;
    }

    const baseString = `v0:${timestamp}:${req.rawBody}`;
    const expectedSignature = `v0=${crypto
      .createHmac('sha256', this.config.signingSecret)
      .update(baseString)
      .digest('hex')}`;

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  }
}

Slash Commands

// Slash command handling
class SlackCommandHandler {
  private commands: Map<string, CommandDefinition> = new Map();

  register(command: CommandDefinition): void {
    this.commands.set(command.name, command);
  }

  middleware(): RequestHandler {
    return async (req, res) => {
      if (!this.verifySignature(req)) {
        return res.status(401).send('Invalid signature');
      }

      const { command, text, user_id, channel_id, team_id, response_url } = req.body;

      const commandDef = this.commands.get(command);
      if (!commandDef) {
        return res.json({
          response_type: 'ephemeral',
          text: `Unknown command: ${command}`,
        });
      }

      // Parse arguments
      const args = this.parseArgs(text, commandDef.argSchema);

      // Acknowledge with loading state
      res.json({
        response_type: 'ephemeral',
        text: 'Processing...',
      });

      try {
        // Execute command
        const result = await commandDef.handler({
          args,
          userId: user_id,
          channelId: channel_id,
          teamId: team_id,
        });

        // Send actual response
        await this.sendResponse(response_url, {
          response_type: result.public ? 'in_channel' : 'ephemeral',
          blocks: result.blocks,
          text: result.text,
        });
      } catch (error) {
        await this.sendResponse(response_url, {
          response_type: 'ephemeral',
          text: `Error: ${(error as Error).message}`,
        });
      }
    };
  }

  private parseArgs(text: string, schema: ArgSchema): Record<string, unknown> {
    const args: Record<string, unknown> = {};
    const parts = text.trim().split(/\s+/);

    for (const [name, def] of Object.entries(schema)) {
      if (def.positional !== undefined) {
        args[name] = parts[def.positional];
      } else if (def.flag) {
        const flagIndex = parts.indexOf(`--${name}`);
        if (flagIndex !== -1) {
          args[name] = parts[flagIndex + 1] ?? true;
        }
      }
    }

    return args;
  }
}

// Command definition
interface CommandDefinition {
  name: string;
  description: string;
  argSchema: ArgSchema;
  handler: (ctx: CommandContext) => Promise<CommandResult>;
}

// Example command
const askCommand: CommandDefinition = {
  name: '/ask',
  description: 'Ask RuvBot a question',
  argSchema: {
    question: { positional: 0, required: true },
    context: { flag: true },
  },
  handler: async (ctx) => {
    const session = await sessionManager.getOrCreate(ctx.userId, ctx.channelId);
    const response = await agent.process(session, ctx.args.question as string);

    return {
      public: false,
      text: response.content,
      blocks: formatResponseBlocks(response),
    };
  },
};

Block Kit Builder

// Fluent Block Kit builder
class BlockKitBuilder {
  private blocks: Block[] = [];

  section(text: string): this {
    this.blocks.push({
      type: 'section',
      text: { type: 'mrkdwn', text },
    });
    return this;
  }

  divider(): this {
    this.blocks.push({ type: 'divider' });
    return this;
  }

  context(...elements: string[]): this {
    this.blocks.push({
      type: 'context',
      elements: elements.map(e => ({ type: 'mrkdwn', text: e })),
    });
    return this;
  }

  actions(actionId: string, buttons: Button[]): this {
    this.blocks.push({
      type: 'actions',
      block_id: actionId,
      elements: buttons.map(b => ({
        type: 'button',
        text: { type: 'plain_text', text: b.text },
        action_id: b.actionId,
        value: b.value,
        style: b.style,
      })),
    });
    return this;
  }

  input(label: string, actionId: string, options: InputOptions): this {
    this.blocks.push({
      type: 'input',
      label: { type: 'plain_text', text: label },
      element: {
        type: options.multiline ? 'plain_text_input' : 'plain_text_input',
        action_id: actionId,
        multiline: options.multiline,
        placeholder: options.placeholder
          ? { type: 'plain_text', text: options.placeholder }
          : undefined,
      },
    });
    return this;
  }

  build(): Block[] {
    return this.blocks;
  }
}

// Usage example
const responseBlocks = new BlockKitBuilder()
  .section('Here is what I found:')
  .divider()
  .section(responseText)
  .context(`Generated in ${latencyMs}ms`)
  .actions('feedback', [
    { text: 'Helpful', actionId: 'feedback_positive', value: responseId, style: 'primary' },
    { text: 'Not helpful', actionId: 'feedback_negative', value: responseId },
  ])
  .build();

Webhook Integration

Inbound Webhooks

// Inbound webhook configuration
interface WebhookEndpoint {
  id: string;
  path: string;  // e.g., "/webhooks/github"
  method: 'POST' | 'PUT';
  secretKey?: string;
  signatureHeader?: string;
  signatureAlgorithm?: 'hmac-sha256' | 'hmac-sha1';
  handler: WebhookHandler;
  rateLimit?: RateLimitConfig;
}

class InboundWebhookRouter {
  private endpoints: Map<string, WebhookEndpoint> = new Map();

  register(endpoint: WebhookEndpoint): void {
    this.endpoints.set(endpoint.path, endpoint);
  }

  middleware(): RequestHandler {
    return async (req, res, next) => {
      const endpoint = this.endpoints.get(req.path);
      if (!endpoint) {
        return next();
      }

      // Rate limiting
      if (endpoint.rateLimit) {
        const allowed = await this.rateLimiter.check(
          `webhook:${endpoint.id}:${req.ip}`,
          endpoint.rateLimit
        );
        if (!allowed) {
          return res.status(429).json({ error: 'Rate limit exceeded' });
        }
      }

      // Signature verification
      if (endpoint.secretKey) {
        if (!this.verifySignature(req, endpoint)) {
          return res.status(401).json({ error: 'Invalid signature' });
        }
      }

      try {
        const result = await endpoint.handler({
          body: req.body,
          headers: req.headers,
          query: req.query,
        });

        res.status(result.status ?? 200).json(result.body ?? { ok: true });
      } catch (error) {
        this.logger.error('Webhook handler error', { error, endpoint: endpoint.id });
        res.status(500).json({ error: 'Internal error' });
      }
    };
  }

  private verifySignature(req: Request, endpoint: WebhookEndpoint): boolean {
    const signatureHeader = endpoint.signatureHeader ?? 'x-signature';
    const providedSignature = req.headers[signatureHeader.toLowerCase()] as string;

    if (!providedSignature) return false;

    const algorithm = endpoint.signatureAlgorithm ?? 'hmac-sha256';
    const expectedSignature = crypto
      .createHmac(algorithm.replace('hmac-', ''), endpoint.secretKey!)
      .update(req.rawBody)
      .digest('hex');

    // Handle various signature formats
    const normalizedProvided = providedSignature
      .replace(/^sha256=/, '')
      .replace(/^sha1=/, '');

    return crypto.timingSafeEqual(
      Buffer.from(normalizedProvided),
      Buffer.from(expectedSignature)
    );
  }
}

Outbound Webhooks

// Outbound webhook delivery
class OutboundWebhookDispatcher {
  constructor(
    private queue: Queue<WebhookDelivery>,
    private storage: WebhookStorage,
    private http: HttpClient
  ) {}

  async dispatch(
    webhookId: string,
    event: WebhookEvent,
    options?: DispatchOptions
  ): Promise<string> {
    const webhook = await this.storage.findById(webhookId);
    if (!webhook || !webhook.isEnabled) {
      throw new Error(`Webhook ${webhookId} not found or disabled`);
    }

    const deliveryId = crypto.randomUUID();
    const payload = this.buildPayload(event, webhook);
    const signature = this.sign(payload, webhook.secret);

    // Queue for delivery
    await this.queue.add(
      'deliver',
      {
        deliveryId,
        webhookId,
        url: webhook.url,
        payload,
        signature,
        headers: webhook.headers,
      },
      {
        attempts: 10,
        backoff: { type: 'exponential', delay: 1000 },
        removeOnComplete: 100,
        removeOnFail: 1000,
      }
    );

    return deliveryId;
  }

  private buildPayload(event: WebhookEvent, webhook: Webhook): string {
    return JSON.stringify({
      id: crypto.randomUUID(),
      type: event.type,
      timestamp: new Date().toISOString(),
      data: event.data,
      webhook_id: webhook.id,
    });
  }

  private sign(payload: string, secret: string): string {
    const timestamp = Math.floor(Date.now() / 1000);
    const signaturePayload = `${timestamp}.${payload}`;
    const signature = crypto
      .createHmac('sha256', secret)
      .update(signaturePayload)
      .digest('hex');
    return `t=${timestamp},v1=${signature}`;
  }
}

// Webhook event types
type WebhookEventType =
  | 'session.created'
  | 'session.ended'
  | 'message.received'
  | 'message.sent'
  | 'memory.created'
  | 'skill.executed'
  | 'error.occurred';

interface WebhookEvent {
  type: WebhookEventType;
  data: Record<string, unknown>;
}

LLM Provider Integration

Provider Abstraction

// Unified LLM provider interface
interface LLMProvider {
  // Basic completion
  complete(
    messages: Message[],
    options: CompletionOptions
  ): Promise<Completion>;

  // Streaming completion
  stream(
    messages: Message[],
    options: StreamOptions
  ): AsyncGenerator<Token, Completion, void>;

  // Token counting
  countTokens(text: string): Promise<number>;

  // Model info
  getModel(): ModelInfo;

  // Health check
  isHealthy(): Promise<boolean>;
}

interface CompletionOptions {
  maxTokens?: number;
  temperature?: number;
  topP?: number;
  stopSequences?: string[];
  tools?: Tool[];
}

interface Completion {
  content: string;
  finishReason: 'stop' | 'length' | 'tool_use';
  usage: {
    inputTokens: number;
    outputTokens: number;
  };
  toolCalls?: ToolCall[];
}

Anthropic Claude Provider

// Claude provider implementation
class ClaudeProvider implements LLMProvider {
  private client: AnthropicClient;
  private circuitBreaker: CircuitBreaker;

  constructor(config: ClaudeConfig) {
    this.client = new Anthropic({
      apiKey: config.apiKey,
      baseURL: config.baseURL,
    });

    this.circuitBreaker = new CircuitBreaker({
      failureThreshold: 5,
      resetTimeout: 30000,
    });
  }

  async complete(
    messages: Message[],
    options: CompletionOptions
  ): Promise<Completion> {
    return this.circuitBreaker.execute(async () => {
      const response = await this.client.messages.create({
        model: 'claude-sonnet-4-20250514',
        max_tokens: options.maxTokens ?? 1024,
        temperature: options.temperature ?? 0.7,
        messages: this.formatMessages(messages),
        tools: options.tools?.map(this.formatTool),
      });

      return this.parseResponse(response);
    });
  }

  async *stream(
    messages: Message[],
    options: StreamOptions
  ): AsyncGenerator<Token, Completion, void> {
    const stream = await this.client.messages.stream({
      model: 'claude-sonnet-4-20250514',
      max_tokens: options.maxTokens ?? 1024,
      temperature: options.temperature ?? 0.7,
      messages: this.formatMessages(messages),
    });

    let fullContent = '';
    let inputTokens = 0;
    let outputTokens = 0;

    for await (const event of stream) {
      if (event.type === 'content_block_delta') {
        const text = event.delta.text;
        fullContent += text;
        yield { type: 'text', text };
      } else if (event.type === 'message_delta') {
        outputTokens = event.usage?.output_tokens ?? 0;
      } else if (event.type === 'message_start') {
        inputTokens = event.message.usage?.input_tokens ?? 0;
      }
    }

    return {
      content: fullContent,
      finishReason: 'stop',
      usage: { inputTokens, outputTokens },
    };
  }

  private formatMessages(messages: Message[]): AnthropicMessage[] {
    return messages.map(m => ({
      role: m.role === 'user' ? 'user' : 'assistant',
      content: m.content,
    }));
  }
}

Provider Registry with Failover

// Multi-provider registry with automatic failover
class ProviderRegistry {
  private providers: Map<string, LLMProvider> = new Map();
  private primary: string;
  private fallbacks: string[];

  constructor(config: ProviderRegistryConfig) {
    this.primary = config.primary;
    this.fallbacks = config.fallbacks;
  }

  register(name: string, provider: LLMProvider): void {
    this.providers.set(name, provider);
  }

  async complete(
    messages: Message[],
    options: CompletionOptions
  ): Promise<Completion> {
    const providerOrder = [this.primary, ...this.fallbacks];

    for (const providerName of providerOrder) {
      const provider = this.providers.get(providerName);
      if (!provider) continue;

      try {
        // Check health before using
        if (await provider.isHealthy()) {
          const result = await provider.complete(messages, options);
          this.metrics.increment('provider.success', { provider: providerName });
          return result;
        }
      } catch (error) {
        this.logger.warn(`Provider ${providerName} failed`, { error });
        this.metrics.increment('provider.failure', { provider: providerName });
      }
    }

    throw new Error('All LLM providers unavailable');
  }

  async *stream(
    messages: Message[],
    options: StreamOptions
  ): AsyncGenerator<Token, Completion, void> {
    const provider = this.providers.get(this.primary);
    if (!provider) {
      throw new Error(`Primary provider ${this.primary} not found`);
    }

    // Streaming doesn't support automatic failover (would be disruptive)
    yield* provider.stream(messages, options);
  }
}

Circuit Breaker

// Circuit breaker for external service protection
class CircuitBreaker {
  private state: 'closed' | 'open' | 'half-open' = 'closed';
  private failures = 0;
  private lastFailureTime = 0;
  private successesSinceHalfOpen = 0;

  constructor(private config: CircuitBreakerConfig) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailureTime > this.config.resetTimeout) {
        this.state = 'half-open';
        this.successesSinceHalfOpen = 0;
      } else {
        throw new CircuitBreakerOpenError();
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    if (this.state === 'half-open') {
      this.successesSinceHalfOpen++;
      if (this.successesSinceHalfOpen >= this.config.successThreshold) {
        this.state = 'closed';
        this.failures = 0;
      }
    } else {
      this.failures = 0;
    }
  }

  private onFailure(): void {
    this.failures++;
    this.lastFailureTime = Date.now();

    if (this.failures >= this.config.failureThreshold) {
      this.state = 'open';
    }
  }

  getState(): CircuitBreakerState {
    return {
      state: this.state,
      failures: this.failures,
      lastFailureTime: this.lastFailureTime,
    };
  }
}

interface CircuitBreakerConfig {
  failureThreshold: number;   // Failures before opening
  successThreshold: number;   // Successes in half-open to close
  resetTimeout: number;       // ms before trying half-open
}

Consequences

Benefits

  1. Unified Interface: All integrations exposed through consistent APIs
  2. Resilience: Circuit breakers and retries prevent cascade failures
  3. Extensibility: Easy to add new providers and integrations
  4. Observability: Comprehensive metrics and logging
  5. Security: Proper signature verification and authentication

Trade-offs

Benefit Trade-off
Abstraction Some provider-specific features hidden
Circuit breaker Delayed recovery after incidents
Retry logic Potential duplicate processing
Async processing Eventually consistent state

  • ADR-001: Architecture Overview
  • ADR-004: Background Workers (webhook delivery)

Revision History

Version Date Author Changes
1.0 2026-01-27 RuVector Architecture Team Initial version