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
| Topic | Partitions key | Retention | Producers |
|---|---|---|---|
room-events | room_id | 7 d | Room, Signaling, SFU-webhook bridge |
presence-events | user_id | 24 h | Presence |
moderation-events | room_id | 30 d | Room |
qos-events | session_id | 3 d | Analytics ingest |
auth-events | user_id | 30 d | Auth |
webhook-dlq | — | 14 d | Notification (failed deliveries) |
schedule-events | tenant_id | 14 d | Scheduler |
schedule-dlq | — | 14 d | Scheduler (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
| Type | data | Emitted 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
| Type | data |
|---|---|
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
| Type | data |
|---|---|
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.
| Type | data | Emitted when | Path |
|---|---|---|---|
schedule.created / updated / deleted | { schedule_id, name, cron_expr, timezone, action, version } | API mutation | outbox |
schedule.paused / resumed | { schedule_id, reason, actor } | tenant or system action | outbox |
schedule.triggered | { schedule_id, name, run_id, scheduled_for, payload } | occurrence fires with action=event | outbox (product-critical) |
schedule.run.started | { schedule_id, run_id, scheduled_for, attempt } | executor claims the run | best-effort |
schedule.run.succeeded | { schedule_id, run_id, scheduled_for, attempts, duration_ms, response_status } | 2xx from the target | best-effort |
schedule.run.failed | { schedule_id, run_id, scheduled_for, attempts, error_class, last_status } | retries exhausted | outbox |
schedule.run.skipped | { schedule_id, run_id, reason } | overlap / quota / suspended / stale | best-effort |
schedule.disabled_auto | { schedule_id, reason, consecutive_failures, admin_emails } | failure breaker trips | outbox |
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 isroom.closedand 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 inwebhook-dlqwith tenant-visible delivery logs. Tenants subscribe per event-type via tenant settings.
Schema Evolution Rules
- Additive fields only within a
type; never repurpose or remove — addroom.participant.joined.v2style new types for breaking shape changes. - Consumers must ignore unknown fields and unknown types.
- Every schema lives in
/contracts/events/in the monorepo (source of truth, CI-checked against fixtures).