Disciplines · Decisions (ADRs)

ADR-0004: Eventing Strategy - Event Broker and Contract Patterns

The Oshun monorepo requires a unified eventing strategy to enable asynchronous communication between services, job processing, and real-time features across all domains (lilith, yemaya, isis, sophia, hathor, bellona).

Accepted · 2026-01-10
11sections8 minread

On this page

Status: Accepted Date: 2026-01-10 Authors: Development Team Reviewers: Architecture Team, Platform Team Supersedes: N/A Superseded by: N/A

Context and Problem Statement#

The Oshun monorepo requires a unified eventing strategy to enable asynchronous communication between services, job processing, and real-time features across all domains (lilith, yemaya, isis, sophia, hathor, bellona). Currently, both primary codebases implement different but compatible eventing patterns:

Lilith Eventing (Current State):

  • Custom in-memory queue designed for Redis backing (@lilith/queue)
  • Fastify WebSocket with Redis Pub/Sub for real-time sync
  • Redis for conversation state, read receipts, presence
  • WebRTC SFU for voice/video
  • Pattern: Monolithic real-time service with shared Redis

Yemaya Eventing (Current State):

  • BullMQ for GPU/compute job queues (12 job types)
  • Native ws library with Redis adapter for horizontal scaling
  • Yjs CRDTs with y-websocket for collaborative editing
  • Node heartbeat system for cluster coordination
  • Pattern: Modular packages with production-grade scaling

We need to standardize on:

  1. Event Broker Choice: Which message broker(s) for async communication
  2. Event Contract Patterns: How to define, version, and validate event schemas

This decision affects service decoupling, system scalability, debugging capabilities, and cross-domain integration.

Decision Drivers#

  • Scalability: Support 100k+ concurrent users with high-throughput events
  • Reliability: At-least-once delivery, persistence, and recovery
  • Developer Experience: Easy event publishing/consuming, good debugging
  • Performance: Low latency for real-time features (<50ms)
  • Cost Efficiency: Minimize infrastructure complexity and costs
  • Existing Investment: Leverage proven patterns from both codebases
  • Operational Simplicity: Manageable infrastructure footprint
  • Type Safety: Strongly typed event contracts for compile-time safety
  • Evolvability: Schema versioning for backward compatibility

Considered Options#

Event Broker Options#

Description: Use Redis as the primary message broker with Pub/Sub for real-time fan-out events, Redis Streams for persistent event logs, and BullMQ for job queue management.

Pros:

  • Already In Use: Both codebases use Redis extensively
  • Single Infrastructure: One Redis cluster handles caching, sessions, pub/sub, and queues
  • Low Latency: Sub-millisecond publish, ideal for real-time
  • BullMQ Maturity: Production-proven job queue with UI, metrics, and advanced features
  • Redis Streams: Persistent message log with consumer groups for at-least-once delivery
  • Operational Simplicity: Teams already know Redis
  • Cost Effective: Redis Cluster scales well without additional services

Cons:

  • Memory Bound: Large event volumes require careful memory management
  • Pub/Sub Limitations: No persistence (use Streams for guaranteed delivery)
  • Single Point: Requires Redis Cluster for HA (complexity)

Use Cases:

  • Real-time notifications (Pub/Sub)
  • Job queues (BullMQ)
  • Event sourcing (Redis Streams)
  • Cross-service communication

Option B: Apache Kafka#

Description: Kafka as a distributed event streaming platform for all async communication.

Pros:

  • Massive Scale: Handles millions of events/second
  • Durability: Persistent log with configurable retention
  • Consumer Groups: Parallel processing with exactly-once semantics
  • Replay: Re-process events from any point in history

Cons:

  • Complexity: ZooKeeper/KRaft coordination, partition management
  • Latency: Higher latency than Redis for small messages
  • Operational Overhead: Requires dedicated expertise
  • Overkill: Over-engineered for current scale requirements
  • Cost: Additional infrastructure, managed Kafka is expensive
  • No Current Usage: Neither codebase uses Kafka

Option C: RabbitMQ#

Description: RabbitMQ as a traditional message broker with exchanges and queues.

Pros:

  • Flexible Routing: Exchanges, bindings, topic patterns
  • Guaranteed Delivery: Acknowledgments, dead letter queues
  • Management UI: Good visibility into queues and messages
  • Protocol Support: AMQP, MQTT, STOMP

Cons:

  • Additional Infrastructure: Another service to manage
  • Not In Use: Neither codebase uses RabbitMQ
  • Performance: Slower than Redis for simple pub/sub
  • Complexity: Exchange types, bindings add cognitive load

Option D: Cloud-Native (AWS SQS/SNS, GCP Pub/Sub)#

Description: Use cloud provider's managed messaging services.

Pros:

  • Fully Managed: No infrastructure to maintain
  • Scalable: Automatic scaling
  • Integrated: Works well with other cloud services

Cons:

  • Vendor Lock-In: Tied to specific cloud provider
  • Latency: Higher latency than self-hosted Redis
  • Cost at Scale: Per-message pricing adds up
  • Local Dev: Complex local development setup

Event Contract Options#

Description: Define event contracts using Zod schemas that generate TypeScript types and provide runtime validation.

Pros:

  • Type Safety: Full TypeScript inference from schemas
  • Runtime Validation: Validate events at publish and consume
  • Already In Use: Both codebases use Zod extensively
  • Composable: Extend/merge schemas easily
  • Self-Documenting: Schema is the documentation

Contract Option 2: Protocol Buffers#

Description: Use protobuf for event schema definition with code generation.

Pros:

  • Compact: Binary serialization, smaller payloads
  • Language Agnostic: Generate clients for any language
  • Versioning: Built-in backward compatibility rules

Cons:

  • Build Step: Requires code generation
  • Debugging: Binary format harder to inspect
  • TypeScript Integration: Extra tooling needed

Contract Option 3: JSON Schema#

Description: Use JSON Schema for event validation.

Pros:

  • Standard: Widely supported
  • Language Agnostic: Works everywhere

Cons:

  • Weak TypeScript Integration: Types need separate definitions
  • Verbose: More boilerplate than Zod

Decision Outcome#

Chosen Event Broker: Option A - Redis (Pub/Sub + Streams) + BullMQ Chosen Contract Pattern: Contract Option 1 - Zod Schemas with TypeScript

Justification:

Event Broker Decision#

Redis + BullMQ is the optimal choice because:

  1. Existing Infrastructure: Both Lilith and Yemaya already use Redis for caching, sessions, and pub/sub. Adding Streams and BullMQ is an incremental enhancement, not a new system.

  2. Right-Sized Solution: For the current scale (target: 100k users), Redis provides sufficient throughput (100k+ ops/second). Kafka's complexity is not justified.

  3. Unified Stack: Redis serves multiple purposes:

    • Caching (application cache)
    • Sessions (auth sessions from ADR-0003)
    • Pub/Sub (real-time notifications)
    • Streams (event sourcing, guaranteed delivery)
    • BullMQ (job queues)
  4. Proven at Scale: Yemaya's BullMQ implementation handles GPU jobs with sophisticated features (rate limiting, priority, deduplication). This can be generalized.

  5. Operational Simplicity: One distributed system to manage (Redis Cluster) instead of multiple (Redis + Kafka + RabbitMQ).

Event Contract Decision#

Zod schemas are optimal because:

  1. Type Safety: Full compile-time and runtime validation with zero runtime type erasure.

  2. Existing Investment: Both codebases use Zod for API validation. Event schemas are a natural extension.

  3. Developer Experience: Define once, get types and validation. No code generation step.

  4. Evolvability: Zod's .extend(), .merge(), and .transform() support schema evolution.

Implementation Plan:

  1. Phase 1: Event Infrastructure (Week 1)

    • Create @oshun/events package in libs/shared/events/
    • Define event envelope schema
    • Create Redis Streams publisher/consumer utilities
    • Port BullMQ patterns from Yemaya
  2. Phase 2: Event Contracts (Week 1-2)

    • Define domain event schemas:
      • isis.* (generation events)
      • sophia.* (research events)
      • hathor.* (narrative events)
      • bellona.* (build events)
      • yemaya.* (project events)
      • lilith.* (experience events)
    • Create event registry with versioning
  3. Phase 3: Integration (Week 2-3)

    • Migrate Lilith services to use @oshun/events
    • Migrate Yemaya services to use @oshun/events
    • Implement cross-domain event flows
  4. Phase 4: Observability (Week 3)

    • Event tracing integration
    • Dead letter queue handling
    • Event metrics and dashboards

Success Metrics:

  • Event publish latency < 10ms at p99
  • Event delivery success rate > 99.9%
  • Zero event loss during service restarts
  • Cross-domain events flow correctly
  • All events validated against schemas

Review Schedule: 45 days post-implementation

Implementation Details#

Technical Specifications#

Event Envelope Schema:

typescript
// libs/shared/events/src/envelope.ts
import { z } from 'zod';

export const EventEnvelopeSchema = z.object({
  // Identity
  id: z.string().uuid(),
  type: z.string().regex(/^[a-z]+\.[a-z]+(\.[a-z]+)?$/), // domain.action or domain.entity.action
  version: z.string().regex(/^v\d+$/), // v1, v2, etc.

  // Timing
  timestamp: z.string().datetime(),
  scheduledFor: z.string().datetime().optional(),

  // Source
  source: z.object({
    service: z.string(),
    instance: z.string().optional(),
    traceId: z.string().optional(),
    spanId: z.string().optional(),
  }),

  // Context
  context: z.object({
    userId: z.string().optional(),
    organizationId: z.string().optional(),
    projectId: z.string().optional(),
    correlationId: z.string().optional(),
  }),

  // Payload
  payload: z.unknown(),

  // Metadata
  metadata: z.record(z.unknown()).optional(),
});

export type EventEnvelope = z.infer<typeof EventEnvelopeSchema>;

Domain Event Schemas:

typescript
// libs/contracts/events/src/isis.ts
import { z } from 'zod';

// Isis domain events
export const IsisEvents = {
  'isis.asset.generated': z.object({
    assetId: z.string().uuid(),
    jobId: z.string().uuid(),
    assetType: z.enum(['image', 'video', '3d', 'audio']),
    workflowId: z.string(),
    outputUrl: z.string().url(),
    metadata: z.object({
      duration: z.number().optional(),
      dimensions: z
        .object({
          width: z.number(),
          height: z.number(),
        })
        .optional(),
      fileSize: z.number(),
      format: z.string(),
    }),
    provenance: z.object({
      model: z.string(),
      seed: z.number().optional(),
      prompt: z.string().optional(),
    }),
  }),

  'isis.job.failed': z.object({
    jobId: z.string().uuid(),
    jobType: z.string(),
    errorCode: z.string(),
    errorMessage: z.string(),
    attempts: z.number(),
    willRetry: z.boolean(),
  }),

  'isis.workflow.registered': z.object({
    workflowId: z.string(),
    name: z.string(),
    version: z.string(),
    inputSchema: z.unknown(),
    outputSchema: z.unknown(),
  }),
};

// libs/contracts/events/src/yemaya.ts
export const YemayaEvents = {
  'yemaya.project.created': z.object({
    projectId: z.string().uuid(),
    name: z.string(),
    type: z.enum(['game', 'film', 'animation']),
    ownerId: z.string().uuid(),
    organizationId: z.string().uuid().optional(),
  }),

  'yemaya.asset.approved': z.object({
    assetId: z.string().uuid(),
    projectId: z.string().uuid(),
    approvedBy: z.string().uuid(),
    approvalNote: z.string().optional(),
  }),

  'yemaya.collaboration.joined': z.object({
    sessionId: z.string().uuid(),
    userId: z.string().uuid(),
    documentId: z.string().uuid(),
    role: z.enum(['viewer', 'editor', 'owner']),
  }),
};

// libs/contracts/events/src/lilith.ts
export const LilithEvents = {
  'lilith.meditation.started': z.object({
    sessionId: z.string().uuid(),
    userId: z.string().uuid(),
    meditationType: z.string(),
    guidedBy: z.string().optional(),
    isGroup: z.boolean(),
  }),

  'lilith.meditation.completed': z.object({
    sessionId: z.string().uuid(),
    userId: z.string().uuid(),
    duration: z.number(),
    metrics: z
      .object({
        heartRateVariability: z.number().optional(),
        breathingRate: z.number().optional(),
      })
      .optional(),
  }),

  'lilith.content.purchased': z.object({
    contentId: z.string().uuid(),
    userId: z.string().uuid(),
    contentType: z.enum(['course', 'meditation', 'nft']),
    price: z.object({
      amount: z.number(),
      currency: z.string(),
    }),
    transactionId: z.string(),
  }),
};

Event Publisher:

typescript
// libs/shared/events/src/publisher.ts
import { Redis } from 'ioredis';
import { EventEnvelope, EventEnvelopeSchema } from './envelope';
import { randomUUID } from 'crypto';

export interface PublishOptions {
  stream?: string;
  maxLen?: number;
  correlationId?: string;
}

export class EventPublisher {
  constructor(
    private redis: Redis,
    private serviceName: string
  ) {}

  async publish<T>(
    type: string,
    payload: T,
    options: PublishOptions = {}
  ): Promise<string> {
    const eventId = randomUUID();
    const stream = options.stream || 'oshun:events';

    const envelope: EventEnvelope = {
      id: eventId,
      type,
      version: 'v1',
      timestamp: new Date().toISOString(),
      source: {
        service: this.serviceName,
        instance: process.env.INSTANCE_ID,
      },
      context: {
        correlationId: options.correlationId,
      },
      payload,
    };

    // Validate envelope
    EventEnvelopeSchema.parse(envelope);

    // Publish to Redis Stream
    await this.redis.xadd(
      stream,
      'MAXLEN',
      '~',
      options.maxLen || 100000,
      '*',
      'event',
      JSON.stringify(envelope)
    );

    // Also publish to Pub/Sub for real-time subscribers
    await this.redis.publish(`events:${type}`, JSON.stringify(envelope));

    return eventId;
  }
}

Event Consumer:

typescript
// libs/shared/events/src/consumer.ts
import { Redis } from 'ioredis';
import { EventEnvelope, EventEnvelopeSchema } from './envelope';
import { z } from 'zod';

export interface ConsumerOptions {
  stream?: string;
  group: string;
  consumer: string;
  batchSize?: number;
  blockMs?: number;
}

export type EventHandler<T> = (
  event: EventEnvelope & { payload: T }
) => Promise<void>;

export class EventConsumer {
  private handlers = new Map<string, EventHandler<unknown>>();

  constructor(
    private redis: Redis,
    private options: ConsumerOptions
  ) {}

  register<T>(
    eventType: string,
    schema: z.ZodSchema<T>,
    handler: EventHandler<T>
  ): void {
    this.handlers.set(eventType, async (event) => {
      const validated = schema.parse(event.payload);
      await handler({ ...event, payload: validated });
    });
  }

  async start(): Promise<void> {
    const stream = this.options.stream || 'oshun:events';
    const { group, consumer, batchSize = 10, blockMs = 5000 } = this.options;

    // Create consumer group if not exists
    try {
      await this.redis.xgroup('CREATE', stream, group, '0', 'MKSTREAM');
    } catch (e) {
      // Group already exists
    }

    // Consume loop
    while (true) {
      const results = await this.redis.xreadgroup(
        'GROUP',
        group,
        consumer,
        'COUNT',
        batchSize,
        'BLOCK',
        blockMs,
        'STREAMS',
        stream,
        '>'
      );

      if (!results) continue;

      for (const [, messages] of results) {
        for (const [messageId, fields] of messages) {
          try {
            const eventData = fields[1]; // fields is [key, value]
            const envelope = EventEnvelopeSchema.parse(JSON.parse(eventData));

            const handler = this.handlers.get(envelope.type);
            if (handler) {
              await handler(envelope as EventEnvelope & { payload: unknown });
            }

            // Acknowledge message
            await this.redis.xack(stream, group, messageId);
          } catch (error) {
            // Move to dead letter queue
            await this.handleFailedMessage(messageId, error);
          }
        }
      }
    }
  }

  private async handleFailedMessage(
    messageId: string,
    error: unknown
  ): Promise<void> {
    // Implementation: move to DLQ, alert, etc.
  }
}

BullMQ Job Queue:

typescript
// libs/shared/events/src/job-queue.ts
import { Queue, Worker, Job } from 'bullmq';
import { Redis } from 'ioredis';
import { z } from 'zod';

export interface JobQueueOptions {
  name: string;
  redis: Redis;
  defaultJobOptions?: {
    attempts?: number;
    backoff?: { type: 'exponential' | 'fixed'; delay: number };
    removeOnComplete?: number;
    removeOnFail?: number;
  };
}

export class TypedJobQueue<T> {
  private queue: Queue;
  private worker?: Worker;

  constructor(
    private schema: z.ZodSchema<T>,
    private options: JobQueueOptions
  ) {
    this.queue = new Queue(options.name, {
      connection: options.redis,
      defaultJobOptions: {
        attempts: options.defaultJobOptions?.attempts || 3,
        backoff: options.defaultJobOptions?.backoff || {
          type: 'exponential',
          delay: 1000,
        },
        removeOnComplete: options.defaultJobOptions?.removeOnComplete || 1000,
        removeOnFail: options.defaultJobOptions?.removeOnFail || 5000,
      },
    });
  }

  async add(
    name: string,
    data: T,
    opts?: { priority?: number; delay?: number; jobId?: string }
  ): Promise<Job<T>> {
    // Validate data against schema
    const validated = this.schema.parse(data);
    return this.queue.add(name, validated, opts);
  }

  process(handler: (job: Job<T>) => Promise<void>): void {
    this.worker = new Worker(
      this.options.name,
      async (job) => {
        const validated = this.schema.parse(job.data);
        job.data = validated;
        await handler(job);
      },
      { connection: this.options.redis }
    );
  }

  async close(): Promise<void> {
    await this.queue.close();
    await this.worker?.close();
  }
}

Event Flow Architecture#

text
┌─────────────────────────────────────────────────────────────────────────┐
│                         Redis Cluster                                    │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────────┐  │
│  │ Redis Streams   │  │ Redis Pub/Sub   │  │ BullMQ Queues           │  │
│  │ (Event Log)     │  │ (Real-time)     │  │ (Job Processing)        │  │
│  │                 │  │                 │  │                         │  │
│  │ oshun:events    │  │ events:*        │  │ isis:gpu-jobs           │  │
│  │ oshun:dlq       │  │ presence:*      │  │ bellona:builds          │  │
│  │                 │  │ notifications:* │  │ sophia:indexing         │  │
│  └─────────────────┘  └─────────────────┘  └─────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘
           │                    │                        │
           │                    │                        │
    ┌──────▼──────┐      ┌──────▼──────┐         ┌──────▼──────┐
    │ Event       │      │ Real-time   │         │ Job Workers │
    │ Consumers   │      │ Subscribers │         │             │
    │             │      │             │         │ GPU Worker  │
    │ Isis API    │      │ WebSocket   │         │ Build Worker│
    │ Sophia Svc  │      │ Servers     │         │ Index Worker│
    │ Hathor Svc  │      │             │         │             │
    └─────────────┘      └─────────────┘         └─────────────┘

Event Naming Convention#

text
<domain>.<entity>.<action>

Examples:
- isis.asset.generated
- isis.job.failed
- isis.job.progress
- sophia.document.ingested
- sophia.index.updated
- hathor.world.published
- hathor.simulation.completed
- bellona.build.started
- bellona.build.completed
- bellona.export.ready
- yemaya.project.created
- yemaya.asset.approved
- yemaya.collaboration.joined
- lilith.meditation.started
- lilith.content.purchased

Migration Strategy#

From: Separate Lilith and Yemaya eventing systems To: Unified @oshun/events package

Steps:

  1. Create @oshun/events package with unified interfaces
  2. Migrate Yemaya GPU queue to use @oshun/events (already close to target)
  3. Migrate Lilith queue package to use @oshun/events
  4. Create domain event schemas in @oshun/contracts-events
  5. Update services to publish/consume via unified package
  6. Deprecate old queue implementations

Rollback Plan: Services can fall back to direct Redis/BullMQ usage if @oshun/events has issues.

Consequences#

Positive Consequences#

  • Unified Eventing: Single pattern for all async communication
  • Type Safety: Compile-time validation of event payloads
  • Debugging: Structured events with correlation IDs for tracing
  • Scalability: Redis Cluster handles growth without architecture change
  • Operational Simplicity: One infrastructure (Redis) for multiple purposes
  • Cross-Domain Integration: Events flow naturally between domains
  • Event Sourcing Ready: Redis Streams provide persistent event log

Negative Consequences#

  • Redis Dependency: Heavy reliance on Redis availability
  • Schema Discipline: All events must have defined schemas
  • Migration Effort: Existing code needs updating
  • Learning Curve: Teams need to learn unified patterns

Risks and Mitigation#

Risk Probability Impact Mitigation Strategy
Redis cluster failure Low Critical Multi-AZ deployment, automatic failover, monitoring
Event schema breaking changes Medium High Schema versioning, backward compatibility rules
Message loss Low High Redis persistence (AOF), consumer acknowledgments
High memory usage Medium Medium Stream trimming, TTL policies, monitoring alerts
Consumer lag Medium Medium Autoscaling consumers, backpressure handling

Compliance and Security#

Security Implications#

  • Event Encryption: Sensitive payloads encrypted at rest
  • Access Control: Redis ACLs for service-specific access
  • Audit Trail: All events logged with source and context
  • PII Handling: Events should not contain raw PII; use IDs and references

Compliance Requirements#

  • Data Retention: Event streams trimmed per retention policy
  • Audit Logging: Event publish/consume logged for compliance
  • GDPR: User-related events support data subject access requests

Monitoring and Observability#

Metrics to Track#

  • Publishing: Events published/second, publish latency, publish errors
  • Consuming: Consumer lag, processing time, ack rate, DLQ size
  • Queues: Queue depth, worker utilization, job completion rate
  • Infrastructure: Redis memory, CPU, connections

Alerting Strategy#

  • Critical: Consumer lag > 10,000, DLQ growth, Redis master failover
  • Warning: Consumer lag > 1,000, high memory usage, publish latency > 100ms
  • Info: New event types, consumer group changes, stream trimming

Upstream Dependencies#

  • ADR-0001: Git Consolidation (single repo for shared event packages)
  • ADR-0002: pnpm (workspace dependencies)
  • ADR-0003: Unified Auth (event context includes auth info)

Downstream Impacts#

  • All Services: Must adopt @oshun/events for async communication
  • Observability: Tracing integration for event flows
  • API Gateway: May forward events to clients via SSE/WebSocket

References#

External Resources#

Internal Resources#

  • Lilith queue package — lilith/packages/queue/ (pre-consolidation source repo)
  • Yemaya GPU jobs package — yemaya/packages/gpu-jobs/ (pre-consolidation source repo)
  • Yemaya WebSocket package — yemaya/packages/websocket/ (pre-consolidation source repo)

Revision History#

Version Date Author Changes
1.0 2026-01-10 Development Team Initial version