Event Catalog
Status
Active
Date
2026-05-15
Owners
- Platform Backend
Last Verified Commit
(see git log -- docs/architecture/event-catalog.md)
Event Transport Model
servers_v2 uses a shared contracts package and Redis Streams for cross-service
domain-event transport.
Common rules:
- owner service writes domain state and outbox rows in the same DB transaction
- publisher loops read unpublished outbox rows and append to Redis Streams
- consumers use inbox-style deduplication or processed-event tracking
- worker health must reflect successful forward progress, not simple loop survival
Documented Streams
The columns are split into "Currently Emitted" (event types you can
expect to find on the live stream) and "Reserved" (event-type
constants declared in rgb_contracts/events/*.py and surfaced here so
downstream consumers know the namespace, but with no producer wired
yet). Adding a reserved event becomes "currently emitted" requires (a)
landing a producer that writes the outbox row in the same transaction
as the business state change and (b) updating this row.
| Stream | Producer | Consumers | Currently Emitted | Reserved (constant only) |
|---|---|---|---|---|
| wallet stream | wallet_service | rolling_service + promotion_service — only BET_SETTLED_CONFIRMED / BET_ROLLED_BACK_CONFIRMED (see note) | BET_SETTLED_CONFIRMED, BET_ROLLED_BACK_CONFIRMED, ROLLING_COMPLETED_CONFIRMED, DEPOSIT_APPROVED, WITHDRAW_APPROVED | BET_AUTHORIZED, PROMOTION_CREDITED, PROMOTION_REVERSED, COUPON_EXPIRED |
| rolling stream | rolling_service | future consumers / projections | ROLLING_COMPLETED, ROLLING_CANCELED | — |
| player stream | player_service | future consumers / projections | PLAYER_REGISTERED | VIP_LEVEL_CHANGED |
| agent stream | agent_service | future consumers / projections | AGENT_WITHDRAW_CREATED, AGENT_WITHDRAW_APPROVED, AGENT_WITHDRAW_DECLINED, AGENT_WITHDRAW_PAID, AGENT_WITHDRAW_PAY_DECLINED, AGENT_COUPON_GRANTED, AGENT_COUPON_RECYCLED, AGENT_BALANCE_ADJUSTED | — |
Consumer note. "Currently Emitted" lists what
wallet_serviceproduces, not what is consumed. Therolling_serviceandpromotion_serviceevent consumers dispatch onlyBET_SETTLED_CONFIRMEDandBET_ROLLED_BACK_CONFIRMED.ROLLING_COMPLETED_CONFIRMED,DEPOSIT_APPROVEDandWITHDRAW_APPROVEDare emitted to the stream but have no consumer (producer-only); do not read the table as "rolling/promotion consume DepositApproved".
The wallet Reserved column tracks the four event-type constants
declared in servers_v2/shared/contracts/src/rgb_contracts/events/wallet.py
but never written to wallet_outbox by any producer. They are not on
the live stream and consumers cannot rely on them today. They remain
declared so a future producer PR does not need to invent a name.
Authoritative producer enumeration is in
servers_v2/wallet_service/app/services/wallet_bucket_commands.py /
app/api/routes/wallet.py.
Current Consumer Relationships
rolling_service- consumes wallet events to complete or roll back rolling state
promotion_service- consumes wallet bet-settlement events to maintain rebate and other promotion projections
recon_service- currently relies on direct HTTP calls and background polling rather than Redis Streams
admin_service- currently aggregates via HTTP and scheduler jobs rather than stream consumption
Health Requirements
The following rules are required by current ADRs and worker implementations:
- a publish loop that repeatedly fails must surface failure to supervision
- a consumer loop that stops making successful progress must eventually become unhealthy
- scheduler-backed workers must include job-health readiness, not just process heartbeat
Related ADRs:
docs/adr/ADR-003-background-worker-health-must-reflect-forward-progress.mddocs/adr/ADR-004-settlement-jobs-must-fail-loudly-on-non-delivery.md
Multi-Brand Payload Requirement
Per ADR-009, every event listed under "Documented Streams" carries
brand_id as a field on the shared DomainEvent envelope
(servers_v2/shared/contracts/src/rgb_contracts/events/base.py);
per-payload schemas (e.g. events/wallet.py) are not modified
individually.
Producer-side invariant (enforced at outbox write time): the
envelope brand_id MUST equal the owning row's brand_id. The
outbox writer asserts this via either an application-level guard (in
the worker that persists the outbox row) or a DB-level CHECK
constraint that compares outbox.brand_id to the originating row's
brand_id. A producer that violates this invariant fails the write
and is surfaced to supervision.
Schema versioning: DomainEvent.schema_version bumps from 1 to
2 when brand_id is added to the envelope (Phase 1). Producers
emit only schema_version=2 events after Phase 6 deploy. Consumer
behavior:
schema_version >= 2: readbrand_idfrom envelope; applyMULTI_BRAND_ENFORCEMENTsemantics for envelope/target mismatch (observe: log + count + use envelope brand;enforce: reject). Missing or unparseable envelopebrand_idis rejected regardless of mode.schema_version == 1: incrementevent_legacy_schema_total{stream,consumer}, reject, and surface to supervision. The old observe-mode fallback to the seededdefaultbrand was removed by the event-brand-scoping closure.
At Phase 6 producer-deploy, each producer historically emitted one
BRAND_AWARE_PUBLISHING_BEGINS sentinel event so consumers could log
the exact stream offset where the schema bump took effect. (Removed in
T6-E4 / T7-B2: producers no longer emit the sentinel because the
schema-version observability counter (event_legacy_schema_total)
covers the same alarm surface and the sentinel itself was a no-op in
every stream consumer. Historical entries on long-lived streams may
still appear in pre-Phase-13 backlogs and are ignored by current
consumers.)
In this iteration every consumer is brand-agnostic and processes
every event by reading envelope brand_id; per-brand consumer fanout
(separate consumer-group per brand) is not introduced.
Known Gaps
- no stable cross-service event catalog has yet been extracted for every future consumer
STREAM_PROMOTIONwas removed fromstreams.py(Phase 17) — promotion has no outbound stream contract. If a notification-aimed event surface lands later, re-declare it there with at least one active consumer; do not pre-declare empty.- recon currently uses domain-specific worker loops and HTTP integrations rather than a stream contract