2026 03 10 Unified Vitto Master Plan

Documentation for Plans

Unified Vitto Master Plan — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Transform the GoGrab platform from a functional-but-fragile AI system into a resilient, cost-optimized, self-improving super agent infrastructure across all 4 backend APIs.

Architecture: The plan builds bottom-up: instrumentation first (Phase 0), then resilience (Phase 1), then cost optimization (Phase 2-3), then migration cleanup (Phase 4), then new capabilities (Phase 5-9). Each phase produces independently testable, deployable improvements. The shared resilience library (@libs-api/backend-core) is the foundation that all backends consume.

Tech Stack: NestJS backends (3000/3001/3002/3003), MongoDB (5 named connections), Redis (ioredis), BullMQ, Anthropic/OpenAI/Google LLM SDKs, Angular 20+ frontend, Cloudflare Workers (edge layer).

Source Documents:


Codebase Reality Check (from exploration)

Before diving into tasks, here's what actually exists vs. what the plan assumes:

Assumption Reality
No circuit breakers anywhere backend-vitto-api HAS CircuitBreakerService + DeadLetterQueueService in security/resilience/. backend-ai-api has a scattered one in MCPIntegrationService. backend-api has NONE.
No cost tracking Two parallel systems exist: LlmCostTrackerService (only ComputerUse) + LLMUsageTrackingService (only Workflow). ChatService direct calls are UNTRACKED.
No model routing backend-vitto-api HAS SmartRouterService with complexity-based routing + failover chain. backend-ai-api has NO routing — ad-hoc model selection.
No retry on LLM calls backend-vitto-api HAS retry in BaseLLMProvider.withRetry(). backend-ai-api direct Anthropic SDK calls have ZERO retry.
Channel identity has no caching Correct — resolveChannelIdentity() is a raw MongoDB findOne every time.
User preferences have no caching Correct — getMergedPreferences() does 3 parallel MongoDB queries every time.
Redis is available Yes — RedisModule is global in all backends, but only used for rate limiting + Socket.IO in backend-api.
BullMQ is available Only in backend-vitto-api (TaskQueueService). backend-api has NO BullMQ. backend-ai-api has queue infrastructure in WorkflowModule.

Key insight: The plan's Phase 1 task "Extract CircuitBreakerService to @libs-api/backend-core" is the critical first step — Vitto already has the implementation, we just need to lift it to the shared library so all 3 backends can use it.


Chunk 1: Phase 0 (Baselines) + Phase 1 (Resilience Foundation)

Task 1: LLM Call Instrumentation Middleware (Phase 0)

Why: You cannot measure cost reduction without a baseline. Currently, ChatService and ConversationGenomeService LLM calls are completely untracked. This adds universal logging to every LLM call across all backends.

Files:

// libs/libs-api/backend-core/src/lib/schemas/llm-call-log.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ collection: 'llm_call_logs', timestamps: true })
export class LlmCallLog extends Document {
  @Prop({ required: true }) provider: string;        // 'anthropic' | 'openai' | 'google'
  @Prop({ required: true }) model: string;            // 'claude-opus-4-20250514' etc.
  @Prop({ required: true }) callerService: string;    // 'ChatService' | 'ConversationGenomeService' etc.
  @Prop({ required: true }) callerMethod: string;     // 'generateResponse' | 'compile' etc.
  @Prop() userId?: string;
  @Prop() spaceId?: string;
  @Prop() sessionId?: string;
  @Prop({ required: true }) inputTokens: number;
  @Prop({ required: true }) outputTokens: number;
  @Prop() cacheReadTokens?: number;
  @Prop() cacheCreationTokens?: number;
  @Prop({ required: true }) latencyMs: number;
  @Prop({ required: true }) success: boolean;
  @Prop() errorType?: string;                         // 'rate_limit' | 'overloaded' | 'timeout' | 'unknown'
  @Prop({ type: Number }) estimatedCostUsd?: number;  // calculated from AI_MODEL_CAPABILITIES pricing
  @Prop() taskType?: string;                          // 'chat' | 'genome' | 'compression' | 'computer-use' etc.
  @Prop({ type: Object }) metadata?: Record<string, unknown>;
}

export const LlmCallLogSchema = SchemaFactory.createForClass(LlmCallLog);
LlmCallLogSchema.index({ createdAt: 1 }, { expireAfterSeconds: 90 * 24 * 3600 }); // 90-day TTL
LlmCallLogSchema.index({ callerService: 1, createdAt: -1 });
LlmCallLogSchema.index({ userId: 1, createdAt: -1 });
LlmCallLogSchema.index({ provider: 1, model: 1, createdAt: -1 });
// libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { LlmCallLog } from '../schemas/llm-call-log.schema';

export interface LlmCallEntry {
  provider: string;
  model: string;
  callerService: string;
  callerMethod: string;
  userId?: string;
  spaceId?: string;
  sessionId?: string;
  inputTokens: number;
  outputTokens: number;
  cacheReadTokens?: number;
  cacheCreationTokens?: number;
  latencyMs: number;
  success: boolean;
  errorType?: string;
  estimatedCostUsd?: number;
  taskType?: string;
  metadata?: Record<string, unknown>;
}

@Injectable()
export class LlmCallLoggerService {
  private readonly logger = new Logger(LlmCallLoggerService.name);
  private buffer: LlmCallEntry[] = [];
  private flushTimer: NodeJS.Timeout | null = null;
  private readonly BUFFER_SIZE = 20;
  private readonly FLUSH_INTERVAL_MS = 5000;

  constructor(
    @InjectModel(LlmCallLog.name) private readonly llmCallLogModel: Model<LlmCallLog>,
  ) {
    this.flushTimer = setInterval(() => this.flush(), this.FLUSH_INTERVAL_MS);
  }

  /** Fire-and-forget log — never throws, never blocks the caller. */
  log(entry: LlmCallEntry): void {
    this.buffer.push(entry);
    if (this.buffer.length >= this.BUFFER_SIZE) {
      this.flush();
    }
  }

  /** Wraps an async LLM call, logs timing + tokens automatically. */
  async wrap<T extends { usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } }>(
    meta: Pick<LlmCallEntry, 'provider' | 'model' | 'callerService' | 'callerMethod' | 'userId' | 'spaceId' | 'sessionId' | 'taskType' | 'metadata'>,
    fn: () => Promise<T>,
  ): Promise<T> {
    const start = Date.now();
    try {
      const result = await fn();
      const latencyMs = Date.now() - start;
      this.log({
        ...meta,
        inputTokens: result.usage?.input_tokens ?? 0,
        outputTokens: result.usage?.output_tokens ?? 0,
        cacheReadTokens: result.usage?.cache_read_input_tokens,
        cacheCreationTokens: result.usage?.cache_creation_input_tokens,
        latencyMs,
        success: true,
      });
      return result;
    } catch (error: unknown) {
      const latencyMs = Date.now() - start;
      const errType = this.classifyError(error);
      this.log({
        ...meta,
        inputTokens: 0,
        outputTokens: 0,
        latencyMs,
        success: false,
        errorType: errType,
      });
      throw error;
    }
  }

  private classifyError(error: unknown): string {
    const msg = error instanceof Error ? error.message : String(error);
    if (msg.includes('rate_limit') || msg.includes('429')) return 'rate_limit';
    if (msg.includes('overloaded') || msg.includes('529')) return 'overloaded';
    if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) return 'timeout';
    return 'unknown';
  }

  private flush(): void {
    if (this.buffer.length === 0) return;
    const batch = this.buffer.splice(0);
    this.llmCallLogModel.insertMany(batch, { ordered: false }).catch((err) => {
      this.logger.warn(`Failed to flush ${batch.length} LLM call logs: ${err.message}`);
    });
  }

  onModuleDestroy(): void {
    if (this.flushTimer) clearInterval(this.flushTimer);
    this.flush();
  }
}
// libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.module.ts
import { Module, Global } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { LlmCallLog, LlmCallLogSchema } from '../schemas/llm-call-log.schema';
import { LlmCallLoggerService } from './llm-call-logger.service';

@Global()
@Module({
  imports: [
    MongooseModule.forFeature([{ name: LlmCallLog.name, schema: LlmCallLogSchema }]),
  ],
  providers: [LlmCallLoggerService],
  exports: [LlmCallLoggerService],
})
export class LlmCallLoggerModule {}

Add to libs/libs-api/backend-core/src/index.ts:

// Instrumentation
export { LlmCallLoggerModule } from './lib/instrumentation/llm-call-logger.module';
export { LlmCallLoggerService, LlmCallEntry } from './lib/instrumentation/llm-call-logger.service';
export { LlmCallLog, LlmCallLogSchema } from './lib/schemas/llm-call-log.schema';
// libs/libs-api/backend-core/src/lib/instrumentation/__tests__/llm-call-logger.service.spec.ts
import { Test } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { LlmCallLoggerService } from '../llm-call-logger.service';
import { LlmCallLog } from '../../schemas/llm-call-log.schema';

describe('LlmCallLoggerService', () => {
  let service: LlmCallLoggerService;
  let mockModel: { insertMany: jest.Mock };

  beforeEach(async () => {
    mockModel = { insertMany: jest.fn().mockResolvedValue([]) };
    const module = await Test.createTestingModule({
      providers: [
        LlmCallLoggerService,
        { provide: getModelToken(LlmCallLog.name), useValue: mockModel },
      ],
    }).compile();
    service = module.get(LlmCallLoggerService);
  });

  afterEach(() => service.onModuleDestroy());

  it('should buffer logs and flush on threshold', () => {
    for (let i = 0; i < 20; i++) {
      service.log({
        provider: 'anthropic', model: 'claude-haiku-4-5-20251001',
        callerService: 'TestService', callerMethod: 'test',
        inputTokens: 100, outputTokens: 50, latencyMs: 200, success: true,
      });
    }
    expect(mockModel.insertMany).toHaveBeenCalledTimes(1);
    expect(mockModel.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
      expect.objectContaining({ provider: 'anthropic', callerService: 'TestService' }),
    ]), { ordered: false });
  });

  it('should wrap an async call and log success', async () => {
    const result = await service.wrap(
      { provider: 'anthropic', model: 'test', callerService: 'Test', callerMethod: 'run', taskType: 'chat' },
      async () => ({ usage: { input_tokens: 500, output_tokens: 200 }, content: 'hello' }),
    );
    expect(result.content).toBe('hello');
    // Force flush
    service.onModuleDestroy();
    expect(mockModel.insertMany).toHaveBeenCalledWith(
      expect.arrayContaining([expect.objectContaining({ inputTokens: 500, outputTokens: 200, success: true })]),
      { ordered: false },
    );
  });

  it('should wrap an async call and log failure, then rethrow', async () => {
    await expect(service.wrap(
      { provider: 'anthropic', model: 'test', callerService: 'Test', callerMethod: 'run' },
      async () => { throw new Error('429 rate_limit exceeded'); },
    )).rejects.toThrow('429 rate_limit exceeded');
    service.onModuleDestroy();
    expect(mockModel.insertMany).toHaveBeenCalledWith(
      expect.arrayContaining([expect.objectContaining({ success: false, errorType: 'rate_limit' })]),
      { ordered: false },
    );
  });
});

Run: yarn nx test backend-core --testPathPattern=llm-call-logger Expected: 3 tests PASS

In APIs/backend-ai-api/src/app/app.module.ts, add LlmCallLoggerModule to imports array.

In APIs/backend-ai-api/src/app/chat/chat.service.ts, inject LlmCallLoggerService and wrap the this.anthropic.messages.create() call at line ~249 with this.llmCallLogger.wrap(...).

In APIs/backend-ai-api/src/app/sessions/conversation-genome.service.ts, inject and wrap calls at lines ~158 and ~723.

In APIs/backend-ai-api/src/app/sessions/conversation-compressor.service.ts, inject and wrap calls at lines ~218 and ~297.

In APIs/backend-ai-api/src/app/module-files/module-files.service.ts, inject and wrap call at line ~210.

git add libs/libs-api/backend-core/src/lib/instrumentation/ libs/libs-api/backend-core/src/lib/schemas/llm-call-log.schema.ts libs/libs-api/backend-core/src/index.ts APIs/backend-ai-api/src/app/chat/chat.service.ts APIs/backend-ai-api/src/app/sessions/ APIs/backend-ai-api/src/app/module-files/ APIs/backend-ai-api/src/app/app.module.ts
git commit -m "[S201.1] feat: add universal LLM call instrumentation to backend-core"

Task 2: Context Size Logging (Phase 0)

Why: Measures what Vitto actually sends to sub-agents. Determines whether delta-passing (Phase 5) is a 5% or 50% win.

Files:

Before each SmartRouterService.sendWithFailover() call in agent-loop.service.ts, add:

const contextTokenEstimate = messages.reduce((sum, m) => {
  const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
  return sum + Math.ceil(text.length / 3.5);  // rough token estimate
}, 0);
this.logger.log(`[CONTEXT_SIZE] taskId=${task.id} iteration=${iteration} messages=${messages.length} estimatedTokens=${contextTokenEstimate}`);

In delegate() or createDelegation(), before BackendAiClientService.createHandoff():

const payloadSize = JSON.stringify(delegationPayload).length;
this.logger.log(`[DELEGATION_SIZE] parentTask=${parentTaskId} payloadBytes=${payloadSize} estimatedTokens=${Math.ceil(payloadSize / 3.5)}`);
git add APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts APIs/backend-vitto-api/src/app/engine/delegation-manager.service.ts
git commit -m "[S201.2] feat: add context size logging for baseline measurement"

Task 3: Extract CircuitBreakerService to @libs-api/backend-core (Phase 1)

Why: backend-vitto-api has a production-proven circuit breaker. backend-ai-api and backend-api need the same thing. Extract to shared library.

Files:

// libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.types.ts
export type CircuitState = 'closed' | 'open' | 'half_open';

export interface CircuitBreakerConfig {
  failureThreshold: number;    // consecutive failures to open (default 5)
  resetTimeMs: number;         // time in open before half_open (default 60000)
  halfOpenMaxAttempts: number;  // test calls in half_open (default 2)
}

export interface CircuitStatus {
  key: string;
  state: CircuitState;
  failures: number;
  lastFailureAt?: Date;
  lastSuccessAt?: Date;
  openedAt?: Date;
}

Read the existing implementation at APIs/backend-vitto-api/src/app/security/resilience/circuit-breaker.service.ts and create the shared version at libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.service.ts with the same 3-state logic (closed → open → half_open → closed), canExecute(), execute(), recordSuccess(), recordFailure(), configure(), getAll().

// libs/libs-api/backend-core/src/lib/resilience/retry-with-backoff.ts
export interface RetryConfig {
  maxRetries: number;
  baseDelayMs: number;
  maxDelayMs: number;
  retryableErrors?: string[];  // error message patterns
  onRetry?: (attempt: number, error: Error) => void;
}

const DEFAULT_CONFIG: RetryConfig = {
  maxRetries: 3,
  baseDelayMs: 1000,
  maxDelayMs: 30000,
};

export async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  config: Partial<RetryConfig> = {},
): Promise<T> {
  const cfg = { ...DEFAULT_CONFIG, ...config };
  let lastError: Error;

  for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      if (attempt === cfg.maxRetries) break;

      if (cfg.retryableErrors?.length) {
        const isRetryable = cfg.retryableErrors.some((pattern) =>
          lastError.message.includes(pattern),
        );
        if (!isRetryable) break;
      }

      const delay = Math.min(
        cfg.baseDelayMs * Math.pow(2, attempt) + Math.random() * 500,
        cfg.maxDelayMs,
      );
      cfg.onRetry?.(attempt + 1, lastError);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }

  throw lastError!;
}

Read existing at APIs/backend-vitto-api/src/app/security/resilience/dead-letter-queue.service.ts and create shared version at libs/libs-api/backend-core/src/lib/resilience/dead-letter-queue.service.ts.

// libs/libs-api/backend-core/src/lib/resilience/resilience.module.ts
import { Module, Global } from '@nestjs/common';
import { CircuitBreakerService } from './circuit-breaker.service';
import { DeadLetterQueueService } from './dead-letter-queue.service';

@Global()
@Module({
  providers: [CircuitBreakerService, DeadLetterQueueService],
  exports: [CircuitBreakerService, DeadLetterQueueService],
})
export class ResilienceModule {}

Add to libs/libs-api/backend-core/src/index.ts:

// Resilience
export { ResilienceModule } from './lib/resilience/resilience.module';
export { CircuitBreakerService } from './lib/resilience/circuit-breaker.service';
export { CircuitBreakerConfig, CircuitState, CircuitStatus } from './lib/resilience/circuit-breaker.types';
export { DeadLetterQueueService } from './lib/resilience/dead-letter-queue.service';
export { retryWithBackoff, RetryConfig } from './lib/resilience/retry-with-backoff';
// libs/libs-api/backend-core/src/lib/resilience/__tests__/circuit-breaker.service.spec.ts
describe('CircuitBreakerService', () => {
  let service: CircuitBreakerService;

  beforeEach(() => { service = new CircuitBreakerService(); });

  it('starts in closed state', () => {
    expect(service.canExecute('test')).toBe(true);
  });

  it('opens after 5 consecutive failures', async () => {
    for (let i = 0; i < 5; i++) {
      service.recordFailure('test');
    }
    expect(service.canExecute('test')).toBe(false);
  });

  it('transitions to half_open after reset time', async () => {
    service.configure('test', { failureThreshold: 2, resetTimeMs: 50, halfOpenMaxAttempts: 1 });
    service.recordFailure('test');
    service.recordFailure('test');
    expect(service.canExecute('test')).toBe(false);
    await new Promise((r) => setTimeout(r, 60));
    expect(service.canExecute('test')).toBe(true); // half_open
  });

  it('closes again after success in half_open', async () => {
    service.configure('test', { failureThreshold: 2, resetTimeMs: 50, halfOpenMaxAttempts: 1 });
    service.recordFailure('test');
    service.recordFailure('test');
    await new Promise((r) => setTimeout(r, 60));
    service.recordSuccess('test');
    const status = service.getAll().find((s) => s.key === 'test');
    expect(status?.state).toBe('closed');
  });

  it('execute() wraps fn and records success/failure', async () => {
    const result = await service.execute('test', async () => 42);
    expect(result).toBe(42);

    await expect(service.execute('test', async () => { throw new Error('fail'); })).rejects.toThrow('fail');
  });
});
// libs/libs-api/backend-core/src/lib/resilience/__tests__/retry-with-backoff.spec.ts
import { retryWithBackoff } from '../retry-with-backoff';

describe('retryWithBackoff', () => {
  it('returns result on first success', async () => {
    const result = await retryWithBackoff(async () => 42);
    expect(result).toBe(42);
  });

  it('retries on failure and succeeds', async () => {
    let attempts = 0;
    const result = await retryWithBackoff(async () => {
      attempts++;
      if (attempts < 3) throw new Error('transient');
      return 'ok';
    }, { maxRetries: 3, baseDelayMs: 10 });
    expect(result).toBe('ok');
    expect(attempts).toBe(3);
  });

  it('throws after max retries', async () => {
    await expect(retryWithBackoff(
      async () => { throw new Error('permanent'); },
      { maxRetries: 2, baseDelayMs: 10 },
    )).rejects.toThrow('permanent');
  });

  it('respects retryableErrors filter', async () => {
    let attempts = 0;
    await expect(retryWithBackoff(
      async () => { attempts++; throw new Error('not retryable'); },
      { maxRetries: 3, baseDelayMs: 10, retryableErrors: ['transient'] },
    )).rejects.toThrow('not retryable');
    expect(attempts).toBe(1); // no retries
  });
});

Run: yarn nx test backend-core --testPathPattern=resilience Expected: All tests PASS

Replace the body of APIs/backend-vitto-api/src/app/security/resilience/circuit-breaker.service.ts with:

export { CircuitBreakerService } from '@libs-api/backend-core';

Same for dead-letter-queue.service.ts:

export { DeadLetterQueueService } from '@libs-api/backend-core';

Run: yarn nx build backend-vitto-api --skip-nx-cache Expected: BUILD SUCCESS

git add libs/libs-api/backend-core/src/lib/resilience/ libs/libs-api/backend-core/src/index.ts APIs/backend-vitto-api/src/app/security/resilience/
git commit -m "[S201.3] feat: extract CircuitBreaker + retryWithBackoff to @libs-api/backend-core"

Task 4: Add Circuit Breakers to backend-ai-api LLM Calls (Phase 1)

Why: ChatService, ConversationGenomeService, and other direct Anthropic SDK callers have zero resilience. A single API failure throws to the user.

Files:

In APIs/backend-ai-api/src/app/app.module.ts, add ResilienceModule to imports.

In chat.service.ts, inject CircuitBreakerService, wrap the this.anthropic.messages.create() call:

const CB_KEY = 'llm:anthropic:chat';

const response = await retryWithBackoff(
  () => this.circuitBreaker.execute(CB_KEY, () =>
    this.llmCallLogger.wrap(
      { provider: 'anthropic', model, callerService: 'ChatService', callerMethod: 'generateResponse', userId, taskType: 'chat' },
      () => this.anthropic.messages.create({ model, messages, system, max_tokens }),
    ),
  ),
  { maxRetries: 2, baseDelayMs: 1000, retryableErrors: ['rate_limit', 'overloaded', '529', '429', 'ECONNRESET'] },
);

Circuit breaker key: llm:anthropic:genome. Max retries: 1 (these are background tasks, don't delay).

Circuit breaker key: llm:anthropic:compressor. Max retries: 1.

Circuit breaker key: llm:anthropic:module-files. Max retries: 2.

Run: yarn nx build backend-ai-api --skip-nx-cache Expected: BUILD SUCCESS

git add APIs/backend-ai-api/src/app/
git commit -m "[S201.4] feat: add circuit breakers + retry to backend-ai-api LLM calls"

Task 5: Add Circuit Breakers to backend-api External Calls (Phase 1)

Why: Stripe, Cloudflare, and GitLab calls have zero resilience. A Stripe timeout blocks the billing flow.

Files:

Inject CircuitBreakerService. Wrap stripe.customers.create(), stripe.checkout.sessions.create(), stripe.subscriptions.update() etc. with circuit breaker key external:stripe. Use retryWithBackoff with 2 retries for transient errors (network, 5xx), 0 retries for 4xx.

Circuit breaker key: external:cloudflare. Retry on network errors only.

Circuit breaker key: external:gitlab. Retry on RATE_LIMITED and network errors. The existing error mapping in request() makes this clean — just wrap the single HttpService.request() call.

Run: yarn nx build backend-api --skip-nx-cache Expected: BUILD SUCCESS

git add APIs/backend-api/src/app/
git commit -m "[S201.5] feat: add circuit breakers to backend-api external service calls"

Task 6: Add Redis Caching for Channel Identity Resolution (Phase 1)

Why: Channel identity resolution is called on every WhatsApp/Telegram message. Currently hits MongoDB every time. A 5-minute Redis cache eliminates redundant lookups for active conversations.

Files:

describe('resolveChannelIdentity with cache', () => {
  it('returns cached result on second call', async () => {
    // First call hits MongoDB, second call hits Redis
  });

  it('invalidates cache on completeChannelLink', async () => {
    // After linking, cached result should be cleared
  });
});

In users.service.ts, inject RedisService from @libs-api/backend-core. Before the findOne:

const cacheKey = `channel:${channelType}:${channelId}`;
const cached = await this.redisService.get(cacheKey);
if (cached) return JSON.parse(cached);

// ... existing findOne logic ...

await this.redisService.set(cacheKey, JSON.stringify(result), 'EX', 300); // 5min TTL
return result;

After successful link completion:

await this.redisService.del(`channel:${identity.channelType}:${identity.channelId}`);

Expected: PASS

git add APIs/backend-api/src/app/users/
git commit -m "[S201.6] feat: add Redis cache for channel identity resolution (5min TTL)"

Task 7: Add Redis Caching for User Preferences (Phase 1)

Why: getMergedPreferences() does 3 parallel MongoDB queries on every call. A 10-minute cache reduces load significantly for active users.

Files:

const cacheKey = `prefs:${userId}:${spaceId}`;
const cached = await this.redisService.get(cacheKey);
if (cached) return JSON.parse(cached);

// ... existing merge logic ...

await this.redisService.set(cacheKey, JSON.stringify(merged), 'EX', 600); // 10min TTL
return merged;

In the PUT handler for user preferences, delete the cache key.

git add APIs/backend-api/src/app/preferences/
git commit -m "[S201.7] feat: add Redis cache for user preferences (10min TTL)"

Task 8: Provider Health Endpoints (Phase 1)

Why: No way to check if external providers are healthy from monitoring/alerting. Each backend needs a /health/providers endpoint.

Files:

// libs/libs-api/backend-core/src/lib/resilience/health-reporter.service.ts
import { Injectable } from '@nestjs/common';
import { CircuitBreakerService } from './circuit-breaker.service';

export interface ProviderHealth {
  providers: Array<{
    key: string;
    state: string;
    failures: number;
    lastFailure?: Date;
    lastSuccess?: Date;
  }>;
  overall: 'healthy' | 'degraded' | 'unhealthy';
}

@Injectable()
export class HealthReporterService {
  constructor(private readonly circuitBreaker: CircuitBreakerService) {}

  getProviderHealth(): ProviderHealth {
    const circuits = this.circuitBreaker.getAll();
    const openCount = circuits.filter((c) => c.state === 'open').length;
    return {
      providers: circuits,
      overall: openCount === 0 ? 'healthy' : openCount < circuits.length ? 'degraded' : 'unhealthy',
    };
  }
}
git add libs/libs-api/backend-core/src/lib/resilience/ libs/libs-api/backend-core/src/index.ts APIs/backend-ai-api/src/app/infrastructure/ APIs/backend-vitto-api/
git commit -m "[S201.8] feat: add provider health endpoints across all backends"

Task 9: Add Retries to Inter-Service Calls (Phase 1)

Why: All inter-service HTTP calls use firstValueFrom() with no retry. A single network blip loses the request.

Files:

Wrap HTTP calls with retryWithBackoff({ maxRetries: 2, baseDelayMs: 500, retryableErrors: ['ECONNRESET', 'ETIMEDOUT', '503'] }).

Same pattern. Circuit breaker key: interservice:vitto.

Circuit breaker key: interservice:ai-api. Max retries: 2.

Circuit breaker key: interservice:infrastructure. Max retries: 1 (these are often long-running).

Circuit breaker key: interservice:docs. Max retries: 2.

git add APIs/backend-ai-api/src/app/platform-api-client/ APIs/backend-ai-api/src/app/orchestration/ APIs/backend-vitto-api/src/app/connectivity/
git commit -m "[S201.9] feat: add retries + circuit breakers to all inter-service HTTP calls"

Chunk 2: Phase 2 (Semantic Cache + Model Router) + Phase 3 (BullMQ Alerting)

Task 10: Exact-Match Response Cache for LLM Calls (Phase 2)

Why: Start with exact-match caching (hash of normalized prompt + model + temperature). Free wins before investing in semantic similarity.

Files:

// libs/libs-api/backend-core/src/lib/cache/llm-response-cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import * as crypto from 'crypto';

export interface CacheableRequest {
  model: string;
  messages: Array<{ role: string; content: string }>;
  system?: string;
  temperature?: number;
  userId?: string;     // MUST be part of cache key for tenant isolation
  spaceId?: string;    // MUST be part of cache key for tenant isolation
}

@Injectable()
export class LlmResponseCacheService {
  private readonly logger = new Logger(LlmResponseCacheService.name);
  private readonly TTL_SECONDS = 3600; // 1 hour default
  private hits = 0;
  private misses = 0;

  constructor(private readonly redis: RedisService) {}

  private buildKey(req: CacheableRequest): string {
    const normalized = JSON.stringify({
      model: req.model,
      messages: req.messages.map((m) => ({ role: m.role, content: m.content.trim() })),
      system: req.system?.trim(),
      temperature: req.temperature ?? 1,
      userId: req.userId,
      spaceId: req.spaceId,
    });
    return `llm-cache:${crypto.createHash('sha256').update(normalized).digest('hex')}`;
  }

  async get(req: CacheableRequest): Promise<string | null> {
    if (!this.redis.isConnected()) return null;
    try {
      const cached = await this.redis.get(this.buildKey(req));
      if (cached) { this.hits++; return cached; }
      this.misses++;
      return null;
    } catch { return null; }
  }

  async set(req: CacheableRequest, response: string, ttlSeconds?: number): Promise<void> {
    if (!this.redis.isConnected()) return;
    try {
      await this.redis.set(this.buildKey(req), response, 'EX', ttlSeconds ?? this.TTL_SECONDS);
    } catch (err) {
      this.logger.warn(`Cache set failed: ${err}`);
    }
  }

  getStats(): { hits: number; misses: number; hitRate: number } {
    const total = this.hits + this.misses;
    return { hits: this.hits, misses: this.misses, hitRate: total > 0 ? this.hits / total : 0 };
  }
}

Important design decision: Cache key includes userId and spaceId for tenant isolation. "What's my order status?" for user A must NOT return user B's cached answer.

Important exclusion: Do NOT cache requests that include tool definitions (agentic tool-calling responses). Only cache stateless Q&A.

Before the LLM call, check cache. After successful response, store in cache. Skip caching if tools are present in the request.

git commit -m "[S201.10] feat: add exact-match LLM response cache with tenant isolation"

Task 11: Unified Model Router for backend-ai-api (Phase 2)

Why: backend-ai-api has no model routing — every service hardcodes its model. Use the task-type → model-tier mapping from SmartRouterService pattern.

Files:

// libs/libs-api/backend-core/src/lib/routing/model-router.types.ts
export type TaskComplexity = 'simple' | 'moderate' | 'complex';
export type ModelTier = 'frontier' | 'standard' | 'fast';

export interface ModelRouterConfig {
  frontier: string[];   // ['claude-opus-4-20250514', 'gpt-5']
  standard: string[];   // ['claude-sonnet-4-20250514', 'gpt-4.1']
  fast: string[];       // ['claude-haiku-4-5-20251001', 'gpt-4.1-mini']
}

Rule-based routing by task type. Map: chat → standard, genome → fast, compression → fast, module-generation → frontier, computer-use → frontier, pattern-mining → standard.

Replace hardcoded model selection with modelRouter.getModel(taskType, complexity).

git commit -m "[S201.11] feat: add rule-based model router to backend-core"

Task 12: BullMQ Event-Driven Alerting (Phase 3)

Why: No alerting when queues back up, jobs fail, or circuit breakers open. Need event-driven alerts.

Files:

// libs/libs-api/backend-core/src/lib/alerting/alert.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';

export interface Alert {
  severity: 'info' | 'warning' | 'critical';
  source: string;     // 'circuit-breaker', 'queue', 'cost'
  message: string;
  metadata?: Record<string, unknown>;
  timestamp: Date;
}

@Injectable()
export class AlertService {
  private readonly logger = new Logger(AlertService.name);
  private readonly recentAlerts: Alert[] = [];
  private readonly MAX_RECENT = 100;

  constructor(private readonly eventEmitter: EventEmitter2) {}

  emit(alert: Omit<Alert, 'timestamp'>): void {
    const fullAlert = { ...alert, timestamp: new Date() };
    this.recentAlerts.push(fullAlert);
    if (this.recentAlerts.length > this.MAX_RECENT) this.recentAlerts.shift();

    this.logger.warn(`[ALERT:${alert.severity}] ${alert.source}: ${alert.message}`);
    this.eventEmitter.emit('alert', fullAlert);
    // Future: send to Slack webhook, email, etc.
  }

  getRecent(limit = 20): Alert[] {
    return this.recentAlerts.slice(-limit);
  }
}

In CircuitBreakerService, when state transitions to open, call alertService.emit({ severity: 'critical', source: 'circuit-breaker', message: \Circuit ${key} OPENED after ${failures} failures` })`.

Create GET /ai-api/infrastructure/metrics/agents that returns: active agent sessions, tokens consumed in last hour, model distribution, cache hit rate.

git commit -m "[S201.12] feat: add event-driven alerting system with circuit breaker integration"

Chunk 3: Phase 4 (AI Module Migration) + Phase 5 (Agent Runtime Layer)

Task 13: Unified LLM Cost Tracking (Phase 4)

Why: Two parallel, unconnected cost tracking systems exist. Unify them into the LlmCallLoggerService from Task 1, which now becomes the single source of truth.

Files:

Provides getHourlyCost(), getDailyCost(), getCostByUser(), getCostByModel(), getCostByService() — all querying the unified llm_call_logs collection.

Instead of maintaining its own in-memory array + separate MongoDB collection, delegate to the unified logger. Keep the public API (getSummary, getDailyTotals) but read from llm_call_logs.

In LlmCostAggregatorService, add a check: if LLM spend in the last hour exceeds 2x the trailing 7-day hourly average, emit a critical alert via AlertService.

git commit -m "[S201.13] feat: unify LLM cost tracking into single llm_call_logs collection"

Task 14: AI Module Proxy Controllers (Phase 4)

Why: Some AI endpoints live in backend-api (3000) that should be in backend-ai-api (3001). Create proxy controllers in backend-api that forward to backend-ai-api, then deprecate the originals.

Files:

Find all controllers in APIs/backend-api/src/app/ that handle AI-related requests (generation, platform-ai, ai-agents).

For each endpoint, create a thin proxy in backend-api that forwards to backend-ai-api via PlatformApiClientService pattern (reversed direction).

Proxy responses include Deprecation: true and Link: <new-endpoint-url> headers.

git commit -m "[S201.14] feat: add proxy controllers for AI endpoint migration"

Task 15: Agent Session Checkpointing (Phase 5.1)

Why: When a long-running agent task fails (LLM timeout, Railway restart), the entire task is lost. Add state checkpointing at decision boundaries.

Files:

// APIs/backend-vitto-api/src/app/mind/schemas/session-checkpoint.schema.ts
@Schema({ collection: 'session_checkpoints', timestamps: true })
export class SessionCheckpoint extends Document {
  @Prop({ required: true, index: true }) taskId: string;
  @Prop({ required: true }) sessionId: string;
  @Prop({ required: true }) iteration: number;
  @Prop({ required: true, enum: ['pre_tool', 'post_tool', 'pre_llm', 'post_llm'] }) boundary: string;
  @Prop({ type: Object, required: true }) messages: unknown[];  // conversation state
  @Prop({ type: Object }) toolState?: Record<string, unknown>;
  @Prop({ type: Object }) taskTransient?: Record<string, unknown>;
  @Prop({ required: true, enum: ['ANALYZE', 'PLAN', 'EXECUTE', 'VERIFY'] }) phase: string;
  @Prop({ type: Date, default: Date.now, index: true }) createdAt: Date;
}

TTL index: expire checkpoints after 24 hours (they're only needed for crash recovery).

Methods: checkpoint(taskId, state), getLatestCheckpoint(taskId), clearCheckpoints(taskId).

Use Redis for fast writes (primary), MongoDB as durable backup. On checkpoint():

  1. Write to Redis: checkpoint:{taskId} with 1-hour TTL
  2. Fire-and-forget write to MongoDB

On getLatestCheckpoint(): try Redis first, fall back to MongoDB.

Before each tool execution: await this.checkpointService.checkpoint(task.id, { iteration, messages, toolState, phase: 'EXECUTE', boundary: 'pre_tool' }).

After tool results processed: checkpoint(..., { boundary: 'post_tool' }).

When recovering tasks on restart, check for checkpoints. If found, resume from the checkpoint state instead of re-running from scratch.

git commit -m "[S201.15] feat: add session checkpointing for crash recovery"

Task 16: ContextBroker — Delta-Only State Passing (Phase 5.2)

Why: When Vitto delegates to sub-agents, it currently sends more context than necessary. A ContextBroker assembles minimal payloads.

Files:

// APIs/backend-vitto-api/src/app/engine/context-broker.service.ts
@Injectable()
export class ContextBrokerService {
  constructor(
    private readonly spaceContext: SpaceContextService,
    private readonly userBrief: UserBriefService,
  ) {}

  /** Assemble minimal context for a sub-agent delegation. */
  assembleDelegationContext(params: {
    taskDescription: string;
    parentMessages: unknown[];
    lastDelegationResult?: string;
    userId: string;
    spaceId: string;
  }): DelegationContext {
    return {
      task: params.taskDescription,
      // Only the delta — what changed since last delegation
      relevantContext: this.extractRelevantMessages(params.parentMessages, 5),
      previousResult: params.lastDelegationResult,
      userPreferences: this.userBrief.getCompact(params.userId),
      spaceContext: this.spaceContext.getCompact(params.spaceId),
      // Explicitly EXCLUDE: full history, other sub-agent outputs, raw tool logs
    };
  }

  /** Extract only the most recent relevant messages (not full history). */
  private extractRelevantMessages(messages: unknown[], maxMessages: number): unknown[] {
    return messages.slice(-maxMessages);
  }
}

Replace direct payload construction with contextBroker.assembleDelegationContext().

Add logging that compares old payload size vs. new payload size for baseline measurement.

git commit -m "[S201.16] feat: add ContextBroker for delta-only state passing to sub-agents"

Task 17: Break Circular Dependency via BullMQ (Phase 5.3)

Why: backend-ai-api and backend-vitto-api call each other synchronously. If AI-API needs to delegate a task to Vitto, it should use BullMQ (async) instead of synchronous HTTP.

Files:

Uses BullMQ to enqueue tasks for Vitto processing instead of synchronous HTTP POST.

@Injectable()
export class VittoTaskQueueService {
  constructor(@InjectQueue('vitto-tasks') private readonly queue: Queue) {}

  async submitAsync(task: VittoTaskPayload): Promise<string> {
    const job = await this.queue.add('vitto-task', task, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 5000 },
      removeOnComplete: 100,
      removeOnFail: 50,
    });
    return job.id;
  }
}

BullMQ processor that picks up tasks from the shared queue and routes to VittoEngineService.submitTask().

When VITTO_USE_ASYNC_QUEUE=true (default for non-urgent tasks), use the queue. Synchronous HTTP remains for chat messages that need real-time response.

git commit -m "[S201.17] feat: break circular dependency with BullMQ async task delegation"

Chunk 4: Phase 6 (Hierarchical Memory + Skills) + Phase 7 (Cloudflare Edge)

Task 18: FocusService — Active Context Compression (Phase 6, Tier 2)

Why: Sub-agents complete work but raw interaction history stays in the parent context. FocusService compresses completed sub-task output into compact summaries.

Files:

@Injectable()
export class FocusService {
  constructor(private readonly smartRouter: SmartRouterService) {}

  /** Compress raw sub-agent output into a compact summary via fast model. */
  async completeFocus(params: {
    topic: string;
    rawOutput: string;
    sessionId: string;
  }): Promise<FocusSummary> {
    const summary = await this.smartRouter.sendWithFailover({
      messages: [{
        role: 'user',
        content: `Summarize this agent work output in 3-5 bullet points. Focus on: what was done, what was decided, what artifacts were produced.\n\nTopic: ${params.topic}\n\nRaw output:\n${params.rawOutput}`,
      }],
      model: 'claude-haiku-4-5-20251001', // cheap model for compression
      maxTokens: 500,
    });
    return {
      topic: params.topic,
      summary: summary.content,
      compressedAt: new Date(),
      originalTokens: Math.ceil(params.rawOutput.length / 3.5),
      compressedTokens: Math.ceil(summary.content.length / 3.5),
    };
  }
}

After receiving sub-agent results, run through FocusService. Store the summary in the session, discard the raw output from context.

git commit -m "[S201.18] feat: add FocusService for active context compression (Tier 2 memory)"

Task 19: Cross-Session Contextual Memory (Phase 6, Tier 3)

Why: Vitto should remember user preferences and interaction patterns across sessions. "This user prefers concise answers" should persist.

Files:

@Schema({ collection: 'user_context_memories', timestamps: true })
export class UserContextMemory extends Document {
  @Prop({ required: true, index: true }) userId: string;
  @Prop({ index: true }) spaceId?: string;  // null = platform-level
  @Prop({ required: true }) category: string; // 'preference' | 'pattern' | 'expertise' | 'fact'
  @Prop({ required: true }) key: string;       // 'response_style', 'tech_stack', etc.
  @Prop({ required: true }) value: string;     // 'prefers concise answers'
  @Prop({ default: 1 }) confidence: number;    // 0-1, increases with confirmation
  @Prop({ default: 0 }) confirmations: number;
  @Prop() source?: string;                     // 'inferred' | 'explicit' | 'observed'
}

Methods: getForUser(userId, spaceId?), upsert(userId, category, key, value, source), incrementConfidence(id), getHighConfidence(userId, minConfidence).

Tenant isolation: queries ALWAYS filter by userId. Platform-level memories (spaceId = null) are loaded for all spaces. Space-specific memories only for that space.

In buildWebSystemPrompt(), load high-confidence user memories and inject as a "User Profile" section in the system prompt.

In MemoryPersistenceService.extractUserProfileFacts(), persist new facts as UserContextMemory entries.

git commit -m "[S201.19] feat: add cross-session contextual memory (Tier 3) with tenant isolation"

Task 20: Skill Document Generation (Phase 6, Tier 4)

Why: When Vitto solves a complex task successfully, it should write a "Skill Document" — procedural memory for similar future tasks.

Files:

@Schema({ collection: 'skill_documents', timestamps: true })
export class SkillDocument extends Document {
  @Prop({ required: true }) title: string;
  @Prop({ required: true }) taskType: string;
  @Prop({ required: true }) approach: string;        // step-by-step
  @Prop({ type: [String] }) toolsUsed: string[];
  @Prop({ type: [String] }) prerequisites: string[];
  @Prop({ required: true }) outcome: string;
  @Prop({ default: 1 }) successCount: number;
  @Prop({ default: 0 }) failureCount: number;
  @Prop() spaceId?: string;                          // null = platform-level
  @Prop({ type: [Number] }) embedding?: number[];    // vector for similarity search
  @Prop({ default: 'active' }) status: string;
}

Methods: generateSkill(task, result), findRelevantSkills(taskDescription, limit), recordOutcome(skillId, success).

findRelevantSkills() uses MongoDB text search initially (vector search as a future enhancement).

Tenant isolation: Skills with spaceId are only loaded for that space. Platform-level skills (null spaceId) are loaded for all.

After a complex task completes successfully (complexity !== 'simple'), call skillRegistry.generateSkill().

Before starting a task, call skillRegistry.findRelevantSkills(task.description) and inject matches into the system prompt.

git commit -m "[S201.20] feat: add skill document generation + retrieval (Tier 4 memory)"

Task 21: Cloudflare Edge — vitto-gateway Worker (Phase 7)

Why: WhatsApp/Telegram webhooks need instant acknowledgment. If Railway is slow, messages are lost. A Cloudflare Worker acknowledges immediately and forwards async.

Files:

// deployment/cloudflare-workers/vitto-gateway/src/index.ts
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // Webhook routes
    if (url.pathname.startsWith('/webhook/')) {
      const body = await request.text();
      const headers = Object.fromEntries(request.headers);

      // Acknowledge immediately
      ctx.waitUntil(forwardToBackend(env.VITTO_API_URL, url.pathname, body, headers, env));

      return new Response('OK', { status: 200 });
    }

    // Health check
    if (url.pathname === '/health') {
      return new Response(JSON.stringify({ status: 'ok', edge: true }), {
        headers: { 'Content-Type': 'application/json' },
      });
    }

    return new Response('Not Found', { status: 404 });
  },
};

async function forwardToBackend(
  baseUrl: string, path: string, body: string, headers: Record<string, string>, env: Env,
): Promise<void> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const resp = await fetch(`${baseUrl}${path}`, {
        method: 'POST',
        body,
        headers: { ...headers, 'X-Forwarded-By': 'vitto-gateway' },
      });
      if (resp.ok) return;
      if (resp.status < 500) return; // don't retry client errors
    } catch { /* retry */ }
    await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
  }
  // After 3 failures, write to Durable Object dead letter
  // (implement in Task 22)
}
git commit -m "[S201.21] feat: add vitto-gateway Cloudflare Worker for webhook edge handling"

Task 22: Cloudflare Edge — vitto-pulse Health Monitor (Phase 7)

Why: External health monitoring that doesn't depend on Railway being up.

Files:

Cron-triggered Worker that checks health of all 4 backends every 5 minutes. Stores results in KV. Exposes /status endpoint for public status page.

git commit -m "[S201.22] feat: add vitto-pulse Cloudflare Worker for external health monitoring"

Chunk 5: Phase 8 (MCP Gateway) + Phase 9 (Self-Improvement) + Phase 10 (Cleanup)

Task 23: MCP Per-Platform Tool Filtering (Phase 8)

Why: Different agent platforms (Cursor, Claude Code, Claude Desktop, Vitto) need different tool sets. Currently all platforms see all tools.

Files:

Each tool gets a platforms array: ['cursor', 'claude-code', 'claude-desktop', 'vitto', 'cowork'].

In server.ts, when handling tools/list, filter by the detected platform from detectPlatform().

Platform Available Tools
cursor All development tools, session, intelligence
claude-code All development tools, session, intelligence
claude-desktop Read-only tools, session info, find_similar
cowork Testing tools, session, outcome, intelligence
vitto Agent tools, task management
git commit -m "[S201.23] feat: add per-platform tool filtering to MCP gateway"

Task 24: Prompt A/B Testing Infrastructure (Phase 9)

Why: Self-improving prompts need safe A/B testing. Build the infrastructure for comparing prompt variants.

Files:

@Schema({ collection: 'prompt_experiments', timestamps: true })
export class PromptExperiment extends Document {
  @Prop({ required: true }) name: string;
  @Prop({ required: true }) scope: string;           // which prompt section
  @Prop({ required: true }) controlPrompt: string;
  @Prop({ required: true }) candidatePrompt: string;
  @Prop({ default: 0.1 }) candidateTrafficPct: number; // 10% default
  @Prop({ default: 'running' }) status: string;      // 'running' | 'paused' | 'completed'
  @Prop({ type: Object }) metrics: {
    control: { completionRate: number; avgTokens: number; avgLatencyMs: number; userRating: number };
    candidate: { completionRate: number; avgTokens: number; avgLatencyMs: number; userRating: number };
  };
  @Prop() minSampleSize: number;
  @Prop() maxDurationDays: number;
}

Methods: getActiveExperiment(scope), selectVariant(experimentId, userId) (deterministic based on userId hash), recordOutcome(experimentId, variant, metrics), evaluate(experimentId) (statistical comparison, requires ALL 4 metrics equal or better).

Critical rule: Candidates are NEVER auto-promoted. evaluate() produces a recommendation that a human reviews.

Check for active experiments on the relevant prompt scope. If found, select variant and use the appropriate prompt.

git commit -m "[S201.24] feat: add prompt A/B testing infrastructure (Phase 9 foundation)"

Task 25: Per-User Rate Limiting for LLM Calls (Missing from both plans)

Why: Neither plan addresses what happens when one user floods Vitto. Without per-user rate limiting, a single user can exhaust the LLM budget.

Files:

@Injectable()
export class UserRateLimiterService {
  constructor(private readonly redis: RedisService) {}

  /** Check if user is within their rate limit. Returns { allowed: boolean, remaining: number, resetAt: Date }. */
  async checkLimit(userId: string, tier: 'free' | 'pro' | 'business' | 'enterprise'): Promise<RateLimitResult> {
    const limits = { free: 50, pro: 200, business: 500, enterprise: 2000 }; // calls per hour
    const key = `rate:llm:${userId}`;
    const current = await this.redis.incr(key);
    if (current === 1) await this.redis.expire(key, 3600);
    const limit = limits[tier] ?? limits.free;
    return {
      allowed: current <= limit,
      remaining: Math.max(0, limit - current),
      resetAt: new Date(Date.now() + (await this.redis.ttl(key)) * 1000),
    };
  }
}
git commit -m "[S201.25] feat: add per-user LLM rate limiting"

Task 26: Cost Alert Circuit Breaker (Missing from both plans)

Why: A bad prompt loop or stuck agent can burn through hundreds of dollars in an hour.

Files:

// In CostGuardService, add:
private readonly SESSION_COST_CEILING_USD = parseFloat(process.env.VITTO_SESSION_COST_CEILING_USD ?? '5');

async checkCostCeiling(taskId: string, userId: string): Promise<boolean> {
  const sessionCost = await this.costAggregator.getSessionCost(taskId);
  if (sessionCost >= this.SESSION_COST_CEILING_USD) {
    this.alertService.emit({
      severity: 'critical',
      source: 'cost-guard',
      message: `Session ${taskId} for user ${userId} hit cost ceiling: $${sessionCost.toFixed(2)} >= $${this.SESSION_COST_CEILING_USD}`,
    });
    return false; // block further LLM calls
  }
  return true;
}

Before each LLM call iteration, check cost ceiling. If exceeded, pause and notify user.

git commit -m "[S201.26] feat: add per-session cost ceiling with automatic pause"

Task 27: Environment Variable and Health Standardization (Phase 10)

Why: Cleanup — standardize health endpoints, env vars, and remove deprecated configurations.

Files:

interface StandardHealth {
  status: 'ok' | 'degraded' | 'unhealthy';
  version: string;
  uptime: number;
  providers: ProviderHealth;
  redis: { connected: boolean };
  mongodb: { connected: boolean };
  queues?: { active: number; waiting: number; failed: number };
}
git commit -m "[S201.27] feat: standardize health endpoints and env configuration"

Execution Order & Session Mapping

Session Tasks Phase Est. Effort
S201 Tasks 1-9 Phase 0 + Phase 1 (Resilience) 2-3 sessions
S202 Tasks 10-12 Phase 2 + Phase 3 (Cache + Router + Alerting) 2 sessions
S203 Tasks 13-14 Phase 4 (AI Migration) 1-2 sessions
S204 Tasks 15-17 Phase 5 (Agent Runtime Layer) 3-4 sessions
S205 Tasks 18-20 Phase 6 (Hierarchical Memory + Skills) 3-4 sessions
S206 Tasks 21-22 Phase 7 (Cloudflare Edge) 2-3 sessions
S207 Task 23 Phase 8 (MCP Gateway) 1-2 sessions
S208 Tasks 24-26 Phase 9 + Missing Items (Self-Improvement + Rate Limits + Cost) 2-3 sessions
S209 Task 27 Phase 10 (Cleanup) 1 session

Total: ~20-27 tasks across ~9 sessions (S201-S209)


Decision Framework: When to Stop

Priority Tasks Why
Must build Tasks 1-9 (Phase 0-1) Production is fragile without these
Should build Tasks 10-17 (Phase 2-5) Cost savings + architectural correctness
High-value optional Tasks 18-22 (Phase 6-7) Competitive differentiation
Experimental Tasks 23-26 (Phase 8-9) "10x" bets — build only after foundation is solid
Maintenance Task 27 (Phase 10) Do whenever there's spare capacity