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
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-03
|
||||||
|
deciders: jochen
|
||||||
|
reconstructed: false
|
||||||
|
extends: 0046-events-are-a-relationship.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 47. The shape of an event on the wire
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[ADR 0046](0046-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 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.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 — `<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](0043-a-declaration-is-an-ordered-list-of-owned-resources.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 0046](0046-events-are-a-relationship.md) — events as a relationship; this is their wire shape.
|
||||||
|
- [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) — the precedent: a wire
|
||||||
|
contract, versioned, additions reviewed as security.
|
||||||
|
- [ADR 0001](0001-nodes-communicate-over-a-broker.md) — the broker.
|
||||||
|
- [ADR 0044](0044-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.
|
||||||
Reference in New Issue
Block a user