Event Model
Every event on the platform — whether published by a kernel service or a third-party module — uses the same standard envelope. This guarantees that every consumer can inspect the metadata without deserialising the payload and that OpenTelemetry tracing spans are automatically correlated across services.
Event Envelope
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID v7 | ✅ | Unique event identifier. Time-sortable. Used as idempotency key. |
type | string | ✅ | Event name in {domain}.{entity}.{verb} format, e.g. auth.user.created |
source | string | ✅ | Module or service that published the event, e.g. crm, iam |
tenantId | UUID | ✅ | Injected by API Gateway from JWT. Cannot be set by the publisher. |
data | object | ✅ | Event-specific payload. Schema defined per event type. Max 1 MB total envelope. |
schemaVersion | string | ✅ | Semver string, e.g. "1.0.0". Used for consumer compatibility checks. |
timestamp | ISO 8601 | ✅ | UTC time of event creation on the producer side. |
traceId | string | ✅ | OpenTelemetry trace ID. Propagated from the originating HTTP request. |
Example envelope (JSON over the wire)
{
"id": "01j9pa9ev300000000000000",
"type": "auth.user.created",
"source": "iam",
"tenantId": "01j9p3kz5f00000000000000",
"data": {
"userId": "01j9pa5mz700000000000000",
"roles": ["member"]
},
"schemaVersion": "1.0.0",
"timestamp": "2026-04-15T10:30:00.000Z",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Event Type Naming Convention
Event types follow the {domain}.{entity}.{verb} convention:
| Part | Rule | Examples |
|---|---|---|
domain | Bounded context slug | auth, crm, money, billing |
entity | Noun (singular) | user, contact, wallet, deal |
verb | Past tense | created, updated, deleted, credited, changed |
All parts are lowercase with dots as separators. No hyphens or underscores in the type string.
✅ auth.user.created
✅ crm.deal.won
✅ money.wallet.credited
❌ auth_user_created (underscores)
❌ CRM.Contact.Created (uppercase)
❌ crm.contact-created (hyphen)
Protobuf Schema
All event schemas are declared as Protobuf messages located in
proto/events/{domain}/ in the monorepo. Protobuf guarantees
language-agnostic type safety across Go consumers and TypeScript SDK.
// proto/events/auth/user_created.proto
syntax = "proto3";
package platform.events.auth;
message UserCreatedEvent {
string id = 1;
string type = 2;
string source = 3;
string tenant_id = 4;
string schema_version = 5;
string timestamp = 6;
string trace_id = 7;
message Data {
string user_id = 1;
string email = 2;
repeated string roles = 3;
}
Data data = 8;
}
Backward Compatibility (Protobuf Schema CI)
Schema changes go through automated backward compatibility checks on every pull request:
| Change type | Allowed | Reason |
|---|---|---|
| Add new field | ✅ | Consumers that don't know the field ignore it |
| Add new message | ✅ | No impact on existing consumers |
| Remove field | ❌ Blocked by CI | Consumers expecting the field break |
| Rename field | ❌ Blocked by CI | Wire equivalent to remove + add |
| Change field type | ❌ Blocked by CI | Deserialisation fails in existing consumers |
CI step: buf breaking --against ".git#branch=main"
Config: buf.yaml at repository root
breaking:
use: [FILE]
A PR that contains a breaking .proto change fails CI and cannot be
merged. The only valid strategy for a breaking change is to create a
new event type (auth.user.created.v2) alongside the old one and
migrate consumers before removing the old type.
schemaVersion and Consumer Compatibility
schemaVersion follows semver:
| Change | Version bump |
|---|---|
| New optional field added | Patch (1.0.0 → 1.0.1) |
| New required field added | Minor (1.0.0 → 1.1.0) |
| Breaking change | New event type — never bump major in-place |
The SDK exposes event.schemaVersion so that consumers can implement
version-conditional logic if they need to handle multiple schema
generations during a migration window:
kernel.events().subscribe('auth.user.created', async (event) => {
if (event.schemaVersion === '1.0.0') {
// handle original schema
} else if (event.schemaVersion >= '1.1.0') {
// handle extended schema with new fields
}
});
id Field — UUID v7
id is always a UUID v7 (time-ordered). This provides:
- Idempotency: the same event re-delivered gets the same
id. Consumers storeidto detect and skip duplicates. - Time-sortable: events can be sorted by
idwithout a secondarytimestampsort, which is useful for ClickHouse analytics. - Globally unique: no coordination required between producers.
traceId — Distributed Tracing
traceId is the OpenTelemetry W3C trace ID from the originating
HTTP request. It propagates automatically through:
HTTP request (traceId: 4bf92...)
│
▼ Gateway injects traceId into event envelope
Kafka event (traceId: 4bf92...)
│
▼ Consumer extracts traceId, continues trace span
ClickHouse audit log (traceId: 4bf92...)
Every log line, audit record, and ClickHouse analytics row associated
with a single user action shares the same traceId, making
end-to-end debugging trivial.