--- topic: what runs on it status: accepted date: 2026-09-03 deciders: jochen reconstructed: false extends: 0041-events-are-a-relationship.md --- # 42. The shape of an event on the wire ## Context [ADR 0041](0041-events-are-a-relationship.md) 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](0010-delivery.md) 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 — `..` — with three reserved origins: - `module..` — `module.umami.site.created` - `mesh..` — `mesh.delivery.deployed`, `mesh.provisioning.granted` - `node..` — `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 `..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.dead` receives 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** `.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.` 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`/`on` and 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](0010-delivery.md) 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`) and `mesh-tools` (the binding, queue config, dead-letter). ## References - [ADR 0041](0041-events-are-a-relationship.md) — events as a relationship; this is their wire shape. - [ADR 0010](0010-delivery.md) — the precedent: a wire contract, versioned, additions reviewed as security. - [ADR 0002](0002-nodes-communicate-over-a-broker.md) — the broker. - [ADR 0039](0039-what-the-sdk-holds-and-refuses.md) — `emit`/`on` are stable sdk surface; the binding, queue config and dead-letter are the runtime's, not the sdk's.