117 lines
5.4 KiB
Markdown
117 lines
5.4 KiB
Markdown
---
|
|
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 — `<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 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.
|