events: metadata rides as headers, not in the body (ADR 0047)

emit stamps the ADR 0047 headers — x-event-id, x-source, x-node, x-time,
content-type, and optional x-causation-id / x-schema — and publishes the
body as only the domain payload. on() reconstructs the Event from those
headers. Event gains id (the x-event-id a consumer dedups on) plus the
optional causation/schema. EventHeaders joins the contracts spine.

Supersedes the first cut that carried source/node/time in the body.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-04 00:25:55 +02:00
parent f335bfb9e7
commit 20f7bd2a7b
4 changed files with 95 additions and 22 deletions
+58 -18
View File
@@ -4,45 +4,85 @@
// credential — just the broker's topic routing). Declared as emits/consumes on the manifest so the
// mesh knows the event graph.
//
// This is a thin, audit-ready surface over the broker's publish/subscribe: every event carries who
// emitted it, on which node, and when — so a logger consuming `#` can write a real audit trail.
// Identity and provenance ride as AMQP headers, not in the body (novox/hq ADR 0047): a consumer —
// or the broker, or an audit tool — reads who/when/what without parsing the payload, and the body
// is only the domain payload. This surface hides the header/exchange/queue mechanics; a module
// names a type and a body and never sees the wire.
import { randomUUID } from "node:crypto";
import { broker } from "../messaging/index.js";
import type { Envelope, EventHeaders } from "../contracts/index.js";
/** An event on the mesh: a topic key, its source, and a body — with the metadata audit needs. */
/** An event on the mesh: a topic key, its provenance, and a body — the shape a handler receives. */
export interface Event<T = unknown> {
/** The routing key, e.g. "module.umami.site.created". Dotted, so listeners can match by prefix. */
readonly type: string;
/** The emitting module. */
/** The unique event id (x-event-id) — at-least-once delivery means a handler must dedup on it. */
readonly id: string;
/** The emitting module, context or node (x-source). */
readonly source: string;
/** The node it was emitted from. */
/** The node it was emitted from (x-node). */
readonly node: string;
/** ISO-8601 emit time. */
/** RFC-3339 emit time (x-time). */
readonly at: string;
/** The event or command that caused this one, if any (x-causation-id) — tracing. */
readonly causationId?: string;
/** A version tag for the body's shape, if the emitter set one (x-schema). */
readonly schema?: string;
readonly body: T;
}
/** Extra provenance a caller may attach when emitting. */
export interface EmitOptions {
/** The event or command that caused this one (x-causation-id). */
readonly causationId?: string;
/** A version tag for the body's shape (x-schema). */
readonly schema?: string;
}
/**
* Emit an event. Source and node come from the environment the runtime set for the module
* (MESH_MODULE, MESH_NODE), so a module names only the type and the body.
* (MESH_MODULE, MESH_NODE), so a module names only the type and the body; the sdk stamps the
* ADR 0047 headers (id, source, node, time) and the runtime rides them on the broker.
*/
export async function emit<T>(type: string, body: T): Promise<void> {
const event: Event<T> = {
type,
source: process.env.MESH_MODULE ?? "unknown",
node: process.env.MESH_NODE ?? "unknown",
at: new Date().toISOString(),
body,
export async function emit<T>(type: string, body: T, opts: EmitOptions = {}): Promise<void> {
const source = process.env.MESH_MODULE ?? "unknown";
const node = process.env.MESH_NODE ?? "unknown";
const headers: EventHeaders = {
"x-event-id": randomUUID(),
"x-source": source,
"x-node": node,
"x-time": new Date().toISOString(),
"content-type": "application/json",
...(opts.causationId ? { "x-causation-id": opts.causationId } : {}),
...(opts.schema ? { "x-schema": opts.schema } : {}),
};
await broker().publish<Event<T>>({ key: type, node: event.node, body: event });
await broker().publish<T>({ key: type, node, body, headers });
}
/**
* React to events whose type matches a topic pattern (`*` one segment, `#` any). The audit logger
* is just `on("#", …)`. The handler receives the whole event, metadata included.
* is just `on("#", …)`. The handler receives the reconstructed event — its metadata read back from
* the headers, its body the domain payload.
*/
export async function on<T>(pattern: string, handler: (event: Event<T>) => Promise<void>): Promise<() => void> {
return broker().subscribe<Event<T>>(pattern, async (envelope) => {
await handler(envelope.body);
return broker().subscribe<T>(pattern, async (envelope) => {
await handler(fromEnvelope<T>(envelope));
});
}
/** Rebuild the Event a handler sees from a broker envelope's headers (ADR 0047) and body. */
function fromEnvelope<T>(env: Envelope<T>): Event<T> {
const h = env.headers ?? ({} as EventHeaders);
return {
type: env.key,
id: h["x-event-id"] ?? "",
source: h["x-source"] ?? "unknown",
node: h["x-node"] ?? env.node ?? "unknown",
at: h["x-time"] ?? "",
causationId: h["x-causation-id"],
schema: h["x-schema"],
body: env.body,
};
}