5.4 KiB
topic, status, date, deciders, reconstructed, extends
| topic | status | date | deciders | reconstructed | extends |
|---|---|---|---|---|---|
| what runs on it | accepted | 2026-09-03 | jochen | false | 0041-events-are-a-relationship.md |
42. The shape of an event on the wire
Context
ADR 0041 made events a relationship — emits/consumes, the
graph, the audit logger. It did not say what an event is on the broker: the exchanges, the
routing keys, the headers, the queues and their configuration. That shape is a contract every
emitter and consumer conforms to, exactly as ADR 0010
is for declarations — and it was being decided ad-hoc in code. This settles it, so the sdk and the
runtime implement one contract and a module never reinvents it.
Decision
Two exchanges, kept apart
mesh.events— a durable topic exchange. Every event rides it: module, mesh and node.mesh.rpc— a durable topic exchange. Tool invocations (request/reply) ride it.
Kept separate because RPC is not an event: a # subscription on mesh.events is then a complete
audit of what happened, with none of the invocation traffic.
The routing key is the event type, namespaced by origin
Dotted and hierarchical — <origin>.<name>.<event…> — with three reserved origins:
module.<module>.<event>—module.umami.site.createdmesh.<context>.<event>—mesh.delivery.deployed,mesh.provisioning.grantednode.<node>.<event>—node.anchor.joined,node.anchor.unreachable
Topic matching gives a consumer node.*.joined, module.umami.#, or #. The origin roots are
reserved; everything after is the emitter's own namespace.
Metadata in headers, payload in the body
An event's identity and provenance are AMQP headers, so a consumer — or the broker, or an audit tool — reads who/when/what without parsing the body, and the body is only the domain payload.
Required headers
| header | meaning |
|---|---|
x-event-id |
a unique id — for dedup and audit (delivery is at-least-once, below) |
x-source |
the emitter: the module, context or node name |
x-node |
the node it was emitted from |
x-time |
emit time, RFC-3339 |
content-type |
application/json |
Optional headers
| header | meaning |
|---|---|
x-causation-id |
the event or command that caused this one — tracing |
x-schema |
a version of the body's shape, so a body evolves without silent misreads |
The routing key already carries the type; it is not duplicated as a header. An unknown x-
header is ignored, not refused — unlike a declaration, an event is observed by parties that need
not all understand every header, and refusing would couple every consumer to every emitter's
additions.
Messages are persistent
Events are published persistent (delivery-mode 2). An audit trail that loses events on a broker restart is not one, and the cost is disk the broker already spends on everything durable.
Queues: one per consumer, durable, dead-lettered
- A consumer's queue is
<node>.<module>.events, durable, bound to that module's consumed patterns. Durable so a restart does not drop what arrived while it was down. Manual ack after the handler succeeds — at-least-once. - Prefetch bounds in-flight work (default 32) so one slow consumer does not pull the whole backlog into memory.
- A dead-letter exchange
mesh.events.deadreceives a message rejected past a redelivery limit, so a poison event is set aside for inspection rather than looping forever or vanishing silently. - The audit logger's queue
<node>.audit-logger.events, bound to#, is the same shape — durable, persistent, dead-lettered — because completeness is its whole job. - RPC reply queues are exclusive, auto-delete and server-named; RPC serve queues
serve.<key>are durable and shared, so several runtimes serving one tool key compete rather than each answer.
At-least-once, and consumers are idempotent
A handler may see an event twice — a redelivery after a crash between doing the work and acking.
Consumers must be idempotent, and x-event-id is what makes dedup possible. Exactly-once is not
offered: it is a promise no broker keeps honestly, and saying so is better than pretending.
Consequences
- The event shape is a versioned, enforced contract, not conventions each module reinvents. The
sdk's
emit/onand the runtime's AMQP binding implement it; a module never sees an exchange or queue name. - Metadata-in-headers means the body is exactly the domain payload, and a consumer that only wants provenance never parses it.
- Adding a header or an origin root widens the contract and is reviewed as one — the discipline ADR 0010 applies to the declaration vocabulary.
- The sdk's first cut carried source/node/time in the body; this supersedes that — they move to
headers. That is code to align, in
mesh-sdk(emit/on) andmesh-tools(the binding, queue config, dead-letter).