Every message that travels on the Binions bus — without exception — is wrapped in the same structure: the event envelope. A stable header carries routing and tracing information; a single payload field carries the domain-specific data. One envelope shape for actions, facts, logs, and control events means every daemon parses, routes, traces, and de-duplicates messages the same way.
One shape, four kinds. The envelope is generic over its payload (written
EventEnvelope<P>in Rust). Theevent_kindfield — Action, Fact, Log, or Control — decides routing and semantics; everything else about the envelope is identical across kinds.
| Field | Type | What it carries |
|---|---|---|
event_id | UUID v7 | Globally unique, time-sortable identifier. Also the idempotency key. Generated automatically. |
event_kind | enum | Action · Fact · Log · Control. |
event_type | string | <Domain>.<Name>, e.g. Mail.Received, Database.Inserted. |
event_version | integer | Schema version of the payload. Defaults to 1; bumped on a breaking change. |
producer | string | The daemon that emitted the event, e.g. mailbox-service. |
target_service | string? | Required for Action and Control; omitted for broadcast Facts and Logs. |
timestamp | datetime (UTC) | Producer wall-clock time. Defaults to "now". |
correlation_id | UUID? | Same value across every event of one logical workflow — ties a whole run together. |
causation_id | UUID? | The event_id of the upstream event that caused this one — builds a causation tree. |
aggregate_id | string? | A domain entity identifier — the partition key for per-entity ordering. |
metadata | map | Cross-cutting context: trace_id, tenant_id, user_id, the W3C traceparent, and so on. |
payload | (domain) | The actual data. Its schema is bound by event_type + event_version. |
| Kind | Tense / intent | Audience | Can it be refused? | Stream |
|---|---|---|---|---|
| Action | Imperative — "do this" | Exactly one owning daemon | Yes — the owner decides | actions:<domain> |
| Fact | Past tense — "this happened" | Any number of consumers | No — it already happened | events:<service> |
| Log | Telemetry | The logger | — | events:logs |
| Control | Runtime op | One named daemon | — | control:<service> |
An envelope's logical topic is <event_kind>.<event_type> — for example Fact.Order.Placed — which is what the platform uses for routing decisions and metric labels.
Envelopes are built through a validating builder; a malformed envelope is rejected at construction time rather than failing downstream. The rules:
event_type must look like <Domain>.<Name> — at least one dot, no empty segments, no whitespace.producer must be non-empty and contain no whitespace.target_service — building one without a target is an error.aggregate_id, when present, must be non-empty.kind, event_type, producer, and payload are mandatory.In Rust, the fluent builder fills in sensible defaults (a fresh UUID v7 event_id, the current timestamp, event_version = 1) and validates the rest:
use common_events::{EventEnvelope, Kind};
let envelope = EventEnvelope::builder()
.kind(Kind::Fact)
.event_type("Order.Placed")
.producer("orders-service")
.aggregate_id("order-1001")
.correlation_id(workflow_id) // optional
.payload(OrderPlaced { order_id: "1001".into() })
.build()?; // validates, or returns EventError
assert_eq!(envelope.topic(), "Fact.Order.Placed");
Serialized to JSON (how it is stored in a stream entry), a fact looks like this. Optional fields are simply omitted when empty:
{
"event_id": "0192f7a4-9c31-7e2b-bf10-7a0f3d9c11a2",
"event_kind": "Fact",
"event_type": "Mail.Received",
"event_version": 1,
"producer": "mailbox-service",
"timestamp": "2026-05-31T10:14:07.512Z",
"correlation_id": "0192f7a4-9c31-7e2b-bf10-7a0f3d9c0001",
"aggregate_id": "inbox/INV-7781",
"metadata": { "trace_id": "5f1d…", "via": "imap-faktury" },
"payload": {
"from": "accounts@supplier.example",
"subject": "Invoice INV-7781",
"has_attachments": true
}
}
Tracing for free. Because
correlation_idstays constant across a workflow andcausation_idpoints back to the event that triggered each step, you can reconstruct an entire automation run — and the matchingtraceparentinmetadatalets the same run appear as one trace in Jaeger.