Skip to main content

06 — Event Definitions

Kafka is the domain-event backbone: everything that happened is published once and consumed independently by Notification (webhooks/push), Analytics (ClickHouse ETL), and future services (billing, ML moderation). Real-time in-room fan-out does not ride Kafka — that's Redis pub/sub via Signaling (latency); Kafka is for durability and decoupling.

Envelope

All events share one CloudEvents-inspired envelope (JSON; Avro/Schema Registry when schema count grows):

{
"id": "evt_01H8...", // UUIDv7, idempotency key for consumers
"type": "room.participant.joined",
"source": "signaling", // producing service
"tenant_id": "t_...",
"room_id": "r_...", // when applicable
"occurred_at": "2026-07-13T14:02:11.123Z",
"trace_id": "4bf9...", // OTel correlation
"data": { ... } // type-specific payload
}

Topics

TopicPartitions keyRetentionProducers
room-eventsroom_id7 dRoom, Signaling, SFU-webhook bridge
presence-eventsuser_id24 hPresence
moderation-eventsroom_id30 dRoom
qos-eventssession_id3 dAnalytics ingest
auth-eventsuser_id30 dAuth
webhook-dlq14 dNotification (failed deliveries)
schedule-eventstenant_id14 dScheduler
schedule-dlq14 dScheduler (exhausted deliveries)

Partitioning by room_id guarantees per-room ordering, which is what consumers actually need (a left never overtakes its joined for the same room). Global ordering is explicitly not promised. schedule-events partitions by tenant_id instead — per-tenant ordering, matching platform-events (ADR 0024) — because schedule events have no room.

Event Catalog

room-events

TypedataEmitted when
room.created{ room, created_by }Room Service CRUD
room.activated{ first_participant }first join
room.idle{ idle_since }last participant left
room.closed{ reason, duration_s, peak_participants }lifecycle end
room.participant.joined{ user_id, session_id, role, platform, region }media confirmed (not merely WS open)
room.participant.left{ user_id, session_id, reason, duration_s }leave/kick/disconnect-timeout
room.speaker.changed{ active_user_ids }throttled ≤ 1/5 s per room (Kafka copy; realtime path is Redis)

moderation-events

Typedata
moderation.muted / unmuted{ target_id, actor_id }
moderation.kicked{ target_id, actor_id, reason }
moderation.banned / unbanned{ target_id, actor_id, reason }
moderation.role_changed{ target_id, actor_id, old_role, new_role }

presence-events

Typedata
presence.online / offline{ user_id, region }
presence.idle{ user_id, idle_since }

auth-events

auth.user.registered, auth.login.succeeded, auth.login.failed, auth.refresh.reuse_detected (security signal), auth.api_key.created/revoked.

qos-events

qos.session.summary — emitted at session close with the aggregates that land in session_summaries (ClickHouse).

schedule-events

Envelope is unchanged; room_id is omitted. Schema: contracts/events/schedule-events.json; rationale in ADR 0038.

TypedataEmitted whenPath
schedule.created / updated / deleted{ schedule_id, name, cron_expr, timezone, action, version }API mutationoutbox
schedule.paused / resumed{ schedule_id, reason, actor }tenant or system actionoutbox
schedule.triggered{ schedule_id, name, run_id, scheduled_for, payload }occurrence fires with action=eventoutbox (product-critical)
schedule.run.started{ schedule_id, run_id, scheduled_for, attempt }executor claims the runbest-effort
schedule.run.succeeded{ schedule_id, run_id, scheduled_for, attempts, duration_ms, response_status }2xx from the targetbest-effort
schedule.run.failed{ schedule_id, run_id, scheduled_for, attempts, error_class, last_status }retries exhaustedoutbox
schedule.run.skipped{ schedule_id, run_id, reason }overlap / quota / suspended / stalebest-effort
schedule.disabled_auto{ schedule_id, reason, consecutive_failures, admin_emails }failure breaker tripsoutbox

Outbox rationale (same argument this doc makes for room.closed): schedule.triggered is the product delivery for action=event schedules, and run.failed / disabled_auto drive tenant alerting through the ADR 0024/0025 machinery — none may be lost between DB commit and publish. The scheduler writes them to its own outbox table in scheduler_db (auth's RelayOutbox pattern; the scheduler is the only writer). High-volume observational run.* events go direct — the authoritative record is the run-history API. Exhausted deliveries additionally park the payload on schedule-dlq.

Consumers: Notification (subscription fan-out — its dispatcher already matches on envelope type; only its topic list grows) and Analytics. The scheduler itself consumes platform-events with groups scheduler-tenant-status (suspension projection, ADR 0025) and scheduler-entitlements (entitlement snapshot + quota verdicts, ADR 0030).

Delivery Semantics

  • Producer side: idempotent producers, acks=all, transactional outbox not required for most events (they are observational); the exception is room.closed and moderation events, which Room Service writes via an outbox table in the same PostgreSQL transaction as the state change, relayed to Kafka by a poller — business-critical events are never lost to a crash between DB commit and publish.
  • Consumer side: at-least-once; every consumer dedupes on event.id (idempotent handlers). Consumer groups per service (notification, analytics-etl).
  • Webhooks to tenants: Notification signs payloads (X-Signature: hmac-sha256(...)), retries 5× with exponential backoff (30 s → 1 h), then parks in webhook-dlq with tenant-visible delivery logs. Tenants subscribe per event-type via tenant settings.

Schema Evolution Rules

  1. Additive fields only within a type; never repurpose or remove — add room.participant.joined.v2 style new types for breaking shape changes.
  2. Consumers must ignore unknown fields and unknown types.
  3. Every schema lives in /contracts/events/ in the monorepo (source of truth, CI-checked against fixtures).