Files
hq/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md
T
jschoubben 820ce8fc8c ADR 0047 — the shape of an event on the wire
The wire contract ADR 0046 left open: two topic exchanges (mesh.events,
mesh.rpc, kept apart so # is a clean audit); the routing key as the event
type namespaced by origin (module.*, mesh.*, node.*); metadata in AMQP
headers (required x-event-id/x-source/x-node/x-time/content-type; optional
x-causation-id/x-schema; unknown x- headers ignored) with the body only the
payload; persistent messages; per-consumer durable dead-lettered queues
with prefetch; at-least-once with idempotent consumers (no false exactly-
once). The precedent is ADR 0043 for declarations.

Supersedes the sdk's first cut (metadata in body -> headers); that and the
queue config are code to align in mesh-sdk and mesh-tools.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-03 23:47:58 +02:00

5.5 KiB

status, date, deciders, reconstructed, extends
status date deciders reconstructed extends
accepted 2026-09-03 jochen false 0046-events-are-a-relationship.md

47. The shape of an event on the wire

Context

ADR 0046 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 0043 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.created
  • mesh.<context>.<event> — mesh.delivery.deployed, mesh.provisioning.granted
  • node.<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.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 <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/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 0043 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 0046 — events as a relationship; this is their wire shape.
  • ADR 0043 — the precedent: a wire contract, versioned, additions reviewed as security.
  • ADR 0001 — the broker.
  • ADR 0044 — emit/on are stable sdk surface; the binding, queue config and dead-letter are the runtime's, not the sdk's.