From 820ce8fc8c713827da7e396306781ff6b5747ca8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 23:47:58 +0200 Subject: [PATCH] =?UTF-8?q?ADR=200047=20=E2=80=94=20the=20shape=20of=20an?= =?UTF-8?q?=20event=20on=20the=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../0047-the-shape-of-an-event-on-the-wire.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md diff --git a/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md b/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md new file mode 100644 index 0000000..c3ec99e --- /dev/null +++ b/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md @@ -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 — `..` — 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 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.