Event Anatomy — the envelope, field by field¶
This document dissects the exact bytes that move between Acme Platform services: which
fields the DomainEvent<T> envelope really declares, which component writes each one and
at what moment, which component reads it, and — the part that matters when something goes
wrong — what concretely breaks when a field is missing or wrong. It is written for
engineers adding a producer or consumer, and for anyone debugging a message that arrived
but did not do what it should have. It goes below
platform/event-catalog.md, which describes the
taxonomy and routing grammar; here we stay inside a single message.
Everything below was read off the shipped source. Where a design document (ADR, code comment, or JSDoc) disagrees with the code, both are stated and the disagreement is labelled. Several of the disagreements are load-bearing.
1. Two records, not one¶
Newcomers reasonably assume there is one thing called "the event". There are two, and conflating them is the source of several real bugs in this codebase.
The envelope is DomainEvent<T>, a plain TypeScript interface in
libs/platform/event-contracts/src/index.ts. It is the published language of the platform
— the DDD contract that crosses bounded-context boundaries — and it depends on nothing but
the shared kernel (@acme/domain-primitives), so no bounded context can drag its internals
into a contract.
The outbox row is OutboxEntry, a MikroORM entity in
libs/platform/event-bus/src/lib/outbox-entry.entity.ts, stored in
platform_outbox.outbox_entry. It is transport bookkeeping: status, retry budget, last
error, timestamps. It carries the entire envelope, serialized, in a single jsonb column.
classDiagram
class DomainEvent {
+string eventId
+string eventType
+number version
+TenantId tenantId
+UserId userId
+string correlationId
+string causationId
+string timestamp
+T payload
+string aggregateId
+string id
+string type
+string userEmail
+string[] recipientUserIds
+string ipAddress
+string[] changedFields
}
class OutboxEntry {
+string id
+OutboxEntryType entryType
+string eventType
+Record payload
+string routingKey
+OutboxEntryStatus status
+number retryCount
+string lastError
+Date createdAt
+Date publishedAt
}
class OutboxEntryStatus {
<<enumeration>>
PENDING
PUBLISHING
PUBLISHED
FAILED
}
class OutboxEntryType {
<<enumeration>>
DOMAIN_EVENT
JOB
}
OutboxEntry "1" *-- "1" DomainEvent : payload jsonb holds the whole envelope
OutboxEntry --> OutboxEntryStatus
OutboxEntry --> OutboxEntryType
What the diagram shows. The envelope is nested wholly inside the row. EventPublisher.publish
does exactly this, and nothing else:
const entry = new OutboxEntry();
entry.entryType = OutboxEntryType.DOMAIN_EVENT;
entry.eventType = event.eventType;
entry.payload = event as unknown as Record<string, unknown>;
entry.routingKey = event.eventType;
entry.status = OutboxEntryStatus.PENDING;
em.persist(entry);
Note the consequences of that five-line body, because they recur throughout this document:
eventTypeis stored three times — as the row'sevent_typecolumn, as the row'srouting_keycolumn, and inside the serialized envelope aspayload->>'eventType'. The relay reads the routing key from the column but the version from the envelope, so the two can drift. Section 3.3 shows a shipped case where they do.- The row id is not the event id.
OutboxEntry.iddefaults to a fresh UUID v7 per row;DomainEvent.eventIdis supplied by the caller. If an entry is ever re-created (replay tooling, a manual recovery insert), the row id changes but the domain event id must not. The prototype work on a relay-side dedup table recorded this explicitly: the dedup key must be extracted aspayload->>'eventId', neveroutbox_entry.id, because "a re-queued entry gets a new row id but the SAME domaineventId". - The publisher never touches AMQP. Atomicity with the business write is the entire point of the outbox ADR; the publish is somebody else's problem, deliberately.
The physical row, from the migration that creates it:
CREATE TABLE IF NOT EXISTS "platform_outbox"."outbox_entry" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"entry_type" varchar(20) NOT NULL,
"event_type" varchar(255) NOT NULL,
"payload" jsonb NOT NULL,
"routing_key" varchar(255) NOT NULL,
"status" varchar(20) NOT NULL DEFAULT 'PENDING',
"retry_count" int NOT NULL DEFAULT 0,
"last_error" text NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"published_at" timestamptz NULL,
CONSTRAINT "platform_outbox_outbox_entry_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "idx_outbox_pending"
ON "platform_outbox"."outbox_entry" ("status", "created_at");
Two design decisions are visible in that DDL. status is a bare varchar(20) with no
CHECK constraint, deliberately — the entity comment states that new statuses can be added
at the TypeScript level without a migration. And there is no tenant_id column: the
outbox is a platform-level infrastructure table, exempt from the global tenant filter and
from row-level security. That exemption is what forces every relay and reaper query to
carry { filters: { tenant: false } }, which section 3.4 returns to.
The platform_outbox schema is shared. @Entity({ schema: 'platform_outbox' }) is an
explicit entity schema, which in MikroORM outranks the per-service schema config, so a
service configured for schema: 'trading' still reads and writes
platform_outbox.outbox_entry. The bootstrap job creates the schema once, owned by a group
role every service role joins. Three services each ship an idempotent
CREATE TABLE IF NOT EXISTS migration for the same table; whichever runs first wins.
2. The declared field set¶
This is the interface verbatim from libs/platform/event-contracts/src/index.ts, with the
comments as written:
export interface DomainEvent<T = unknown> {
readonly eventId: string; // UUID v7 (time-ordered)
readonly eventType: string; // e.g. 'trading.deal.locked'
readonly version: number; // schema version for evolution
readonly tenantId: TenantId;
readonly userId: UserId;
readonly correlationId: string;
readonly causationId: string; // the event that caused this
readonly timestamp: string; // ISO 8601
readonly payload: T;
readonly aggregateId?: string; // Aggregate root ID for the entity that emitted this event
// Convenience aliases populated by outbox relay serialization — consumers may use either form
readonly id?: string;
readonly type?: string;
// Optional audit/notification metadata (backward compatible — added M5)
readonly userEmail?: string;
readonly recipientUserIds?: string[];
readonly ipAddress?: string;
readonly changedFields?: string[];
}
Sixteen declared fields: nine required, seven optional. TenantId and UserId are branded
string aliases from the shared kernel —
export type TenantId = string & { readonly __brand: 'TenantId' } — so they are compile-time
distinct but runtime-identical to string. Nothing validates them at runtime; every
producer reaches them by cast (tenantId as TenantId).
| Field | Type | Req. | Stamped by | Read by |
|---|---|---|---|---|
eventId |
string |
yes | producer, uuid.v7() (auth-service uses v4) |
consumer inbox PK, per-effect idempotency keys, audit source_event_id |
eventType |
string |
yes | producer, literal | relay exchange derivation, consumer dispatch switch, audit action mapping |
version |
number |
yes | producer, literal | relay routing-key validation, @EventHandler binding derivation |
tenantId |
TenantId |
yes | producer | consumer tenant filter params, TenantContext.run, audit row |
userId |
UserId |
yes | producer, or the literal 'system' |
audit row user_id |
correlationId |
string |
yes | producer, from request context or a business id | audit row, log join key, downstream envelope inheritance |
causationId |
string |
yes | producer | audit row; only inventory-service uses it as a real parent pointer |
timestamp |
string |
yes | producer, new Date().toISOString() |
audit row timestamp |
payload |
T |
yes | producer | every handler |
aggregateId |
string? |
no | one producer in the whole codebase | five consumer sites, three of them with a non-null assertion |
id, type |
string? |
no | nobody | nobody |
userEmail |
string? |
no | trading-service only, from request context | audit row |
recipientUserIds |
string[]? |
no | trading-service only | notification routing |
ipAddress |
string? |
no | trading, inventory, auth | audit row |
changedFields |
string[]? |
no | trading, inventory, accounting, commission | audit row |
Across apps/platform/*/src, excluding tests, there are 18 literal envelope construction
sites in 16 files. Seventeen of them write version: 1; exactly one writes version: 2.
3. Field by field¶
3.1 eventId¶
Declared readonly eventId: string, documented as UUID v7. Written by the producer,
never by the infrastructure — the relay does not generate one and will happily publish an
entry whose envelope has no eventId at all.
The v7 choice is not cosmetic. UUID v7 embeds a millisecond timestamp in its high bits, so
ids sort in emission order. That makes the inbox primary key (consumer, event_id) insert
in near-append order rather than scattering across the B-tree, and it makes a raw
ORDER BY event_id on a dedup ledger a usable proxy for "in what order did this consumer
see things". Ten of the eleven producers use v7 from the uuid package. auth-service's
AuthEventPublisher uses v4 as uuidv4 instead, so identity events lose the ordering
property. Nothing depends on it today, but the divergence is real and undocumented.
Read by three independent mechanisms, which section 6 treats as a unit:
- The consumer inbox:
processed_eventhas the composite primary key(consumer, event_id), andevent_idis deliberatelytext, notuuid— the entity comment says so explicitly, because "event ids are opaque strings (e.g.evt-200), not guaranteed UUIDs — a uuid column would reject non-UUID ids and break dedup". - Per-effect idempotency: inventory's
stock_movement.event_idisvarchar(255)under a unique indexix_movement_event, populated with the composite`${event.eventId}:${lineItem.lineItemId}`so one event can produce one movement per line item and no more. - Audit:
audit_entry.source_event_id.
What breaks if it is absent or wrong. Absent, the inbox insert writes a null primary
key component and fails; the consumer nacks and the message dead-letters. Duplicated across
two genuinely different state changes — the failure mode that matters most — the second
event is silently swallowed as a redelivery, acme_inventory_consumer_redeliveries_total
increments, the broker gets an ack, and no error surfaces anywhere. This is the single most
dangerous mistake a producer can make with the envelope, because it is invisible. The rule
is: a fresh eventId per emission, never reused, never derived from the aggregate id.
3.2 eventType¶
Declared readonly eventType: string. Written by the producer as a string literal.
The grammar is {context}.{aggregate}.{action} with the action in the past tense, and the
first dotted segment is load-bearing infrastructure, not documentation:
private deriveExchange(eventType: string): string {
const dotIndex = eventType.indexOf('.');
if (dotIndex <= 0) {
throw new Error(`Invalid eventType format: '${eventType}' — expected 'bc.entity.action' pattern`);
}
return `acme.${eventType.substring(0, dotIndex)}`;
}
That function exists twice, once in OutboxRelay (publisher side) and once as
deriveExchangeFromEventType in EventHandlerExplorer (consumer side), with a comment in
the latter naming the former as the single source of truth. They agree today. They are not
mechanically kept in sync.
eventType is also the default routing key (entry.routingKey = event.eventType), the
default consumer queue name (`${exchange}.consumer.${eventType}`), the dispatch
discriminant in every consumer's switch (event.eventType), the key into the audit
service's action map, and the key into the relay's audit-lane secret denylist.
What breaks. A single-segment eventType throws inside Phase 2 of the relay cycle,
which is caught per entry, so the entry burns a retry and eventually lands in FAILED
after five attempts. A valid-looking but wrong prefix is worse: deriveExchange returns
a real exchange name the publishing service has no write permission on, the broker replies
403 ACCESS_REFUSED, and the entry retries into FAILED with that message in last_error.
On the consumer side an unmatched eventType falls through the dispatch switch — most
consumers log and ack, so the event is consumed and discarded.
There is also a compliance gate keyed on the prefix. EventHandlerExplorer refuses to wire
any @EventHandler whose eventType starts with trading., accounting., commission.
or finance.:
Those streams carry financial data, so ingestion by non-financial consumers is gated at the prefix. The mechanism is worth copying and its hazard is worth knowing: the gate reads a string prefix, so renaming an event across that boundary silently changes whether it can be auto-wired at all — in either direction, with no error at the rename site.
3.3 version¶
Declared readonly version: number. Written by the producer. Read by the relay,
in publishToChannel, and only there:
const versionRaw = (entry.payload as { version?: unknown } | undefined)
?.version;
const version = this.resolveEntryVersion(versionRaw);
resolveEntryVersion returns 1 for undefined/null (backward compatibility with
producers predating the versioned-routing-key ADR), null for anything non-integer or
below 1, and the integer otherwise. A null result routes the entry to {exchange}.dlx
with reason VERSION_BINDING_MISMATCH. Otherwise the relay checks that the version agrees
with the
routing key's \.v(\d+)$ suffix — absent suffix means "expect v1":
const suffixMatch = routingKey.match(/\.v(\d+)$/);
const expectedVersion = suffixMatch ? parseInt(suffixMatch[1], 10) : 1;
if (expectedVersion === eventVersion) return { valid: true };
What breaks — and does, today. EventPublisher.publish sets
entry.routingKey = event.eventType, with no version suffix, unconditionally. There is
exactly one producer emitting a version above 1:
// apps/platform/trading-service/src/modules/deal/lock-deal.use-case.ts
const event: DomainEvent<DealLockedEventPayloadV2> = {
eventId: v7(),
eventType: 'trading.deal.locked',
version: 2,
...
};
Its routing key is therefore the bare trading.deal.locked, whose parsed expectation is
v1, against an envelope version of 2. validateVersionRoutingKeyMatch returns invalid, and
dlxRoute publishes a structured mismatch body to acme.trading.dlx with the original
payload preserved on the x-acme-original-payload header. The entry is then marked
PUBLISHED — the relay deliberately does not retry a structural fault. The retention DLQ is
bound to the DLX with the fixed key dead-letter, while dlxRoute publishes with
routingKey = originalRoutingKey, so the copy is not retained either. The WARN log is the
only forensic record.
The relay's own docstring says as much: "<exchange>.dlx is declared but has no bound DLQ
yet, so the published copy is not retained — the WARN log is the current forensic record."
To emit v2 correctly, either the producer must set the routing key explicitly to
trading.deal.locked.v2 (which EventPublisher gives it no way to do) or EventPublisher
must derive the routing key via buildVersionedRoutingKey(eventType, version). Neither is
implemented. buildVersionedRoutingKey exists and is correct; it is called from
publishToBoth and from the consumer-side deriveConsumerWiring, but never from the write
path.
DealLockedEventPayloadV2 — the payload type that adds idempotencyKey — is declared,
exported from the contracts library, and consumed by exactly one producer, the one above.
No consumer binds trading.deal.locked.v2.
3.4 tenantId¶
Declared readonly tenantId: TenantId, required. Written by the producer.
Read by every consumer as the key that unlocks the data layer.
The data layer is fail-closed. The global MikroORM tenant filter is registered with
default: true, so it applies to every entity query unless explicitly disabled, and its
condition function throws rather than returning an empty predicate:
if (args?.platformScope === true) return {};
if (args?.tenantId) return { tenantId: args.tenantId };
throw new Error(
`MikroORM 'tenant' filter is active but no tenant context was provided. ...`
);
An empty-string tenant id is falsy and falls through to the throw — WHERE tenant_id = ''
is never a valid scope. That is why consumers seed the filter from the envelope before
dispatching, and why the outbox relay and reaper must pass { filters: { tenant: false } }
on their find and their nativeUpdate (MikroORM v6 applies registered filters to both).
Omitting it on the write-back would leave every entry stuck in PUBLISHING forever.
The consumer-side read of tenantId has a wrinkle worth knowing:
Both the inventory trading consumer and the notification consumer prefer the payload's
tenantId over the envelope's. Most payload types redundantly carry tenantId, so in
practice they agree; if they ever disagreed, the payload silently wins. Nothing asserts they
match.
What breaks if it is absent. The consumer logs
Event <id> missing tenantId — rejecting to DLX and nacks without requeue. The message
dead-letters and the state change never happens. There is one sanctioned exception:
platform-scoped event types, currently just identity.workspace.discovery-requested, which
legitimately have no tenant because the requesting email may belong to no workspace at all.
Those carry a nil-UUID sentinel tenant, are listed in a PLATFORM_EVENT_TYPES set, run with
the tenant filter left unseeded, and are exempt from the nack.
What breaks if it is wrong. Nothing throws. The consumer scopes its reads and writes to
whatever tenant the envelope names, and the RLS SET LOCAL app.tenant_id emitted by the
transaction subscriber follows suit. A wrong tenantId is a silent cross-tenant write. The
envelope field is trusted absolutely; there is no equivalent of the gateway's header-strip
hook on the message path.
3.5 userId¶
Declared readonly userId: UserId, required. Written by the producer from request
context where one exists, and by the literal string 'system' where none does — background
workers, cron-driven purges, the invoice generator. 'system' is not a UUID, so any
consumer that assumes a parseable UUID here is wrong.
Read by the audit service into audit_entry.user_id, where it is nullable
(userId: event.userId ?? null). Nothing authorises on it — the envelope's userId is
provenance, not permission. Authorisation happened at the gateway before the command that
produced the event.
What breaks. Practically nothing at runtime; the audit trail loses its actor. That is the whole cost, and for a financial system of record it is not nothing.
3.6 correlationId¶
Declared readonly correlationId: string, required. This is where the codebase is
least consistent, so it is worth being precise about where the value originates.
At the edge, the gateway is the sole authority on correlation identity. The ingress hook
strips any client-supplied x-correlation-id (it is in GATEWAY_INJECTED_HEADERS alongside
x-tenant-id, x-user-id, x-permissions, x-platform-scope and friends), and the JWT
guard then mints a fresh one unconditionally:
// Always mint a fresh correlation id — never trust an inbound value, even
// a survivor of the strip hook.
rawRequest.headers["x-correlation-id"] = randomUUID();
Downstream, GatewayIdentityGuard reads it back into RequestUser.correlationId, defaulting
to the empty string when the header is absent. From there each service does something
different:
| Producer | correlationId set to |
|---|---|
TradingEventPublisher |
request correlationId, else a fresh v7() |
LockDealUseCase |
request correlationId, else a fresh v7() |
InventoryEventPublisher |
the source event's correlationId, else a fresh v7() |
UserEventPublisher, TenantEventPublisher |
a correlationId passed in by the caller |
AuthEventPublisher |
a fresh uuidv4() per event, with a TODO(M2) to propagate from request context |
| accounting invoice use-cases | invoice.dealId ?? invoice.id — a business identifier |
| commission calculation | dealId — a business identifier |
| document generation worker | doc.dealId ?? doc.id — a business identifier |
So correlationId means "the request that started this" in trading and inventory, "nothing,
sorry" in auth, and "the deal this is about" in accounting, commission and document. A
single trace joined on correlationId will connect an invoice to its deal but will not
connect either back to the HTTP request that triggered the lock.
Read by the audit service into audit_entry.correlation_id, and inherited by
InventoryEventPublisher when it emits a downstream event.
What breaks. Nothing throws. You lose the ability to answer "what else happened because of this request", which is exactly the question you want to ask during an incident.
3.7 causationId¶
Declared readonly causationId: string, commented "the event that caused this". Only
one producer implements that meaning:
// InventoryEventPublisher
correlationId: sourceEvent?.correlationId ?? v7(),
causationId: sourceEvent?.eventId ?? v7(),
Inventory threads the inbound event through as sourceEvent, inherits its correlationId,
and points causationId at its eventId. That is the textbook chain, and it is real —
purchase-receipted.handler.ts passes
{ correlationId: event.correlationId, eventId: event.eventId, userId: event.userId } when
emitting inventory.stock.updated.
Everyone else does one of three other things:
UserEventPublisher,TenantEventPublisher,AuthEventPublisher:causationId = correlationId. A self-referential pointer that carries no information.- accounting and commission:
causationId = invoice.id/dealId— an aggregate id, not an event id.LockDealUseCaselikewise setscausationId: dealId. TradingEventPublisher:causationId: v7()— a freshly generated UUID that names nothing at all. It is not the parent event, not the aggregate, not the request. It is noise that satisfies a required field.
AuthEventPublisher is at least honest about it, carrying an explicit TODO:
// TODO(M2): Propagate correlationId from request context ... instead of generating a new
// one per event. causationId should be the ID of the command/event that caused this event,
// not the same as correlationId.
Read by the audit service into audit_entry.causation_id. Nothing else reads it.
What breaks. Nothing throws, and that is precisely the problem: the field is required by the type, so every producer fills it with something, and four of the eight fill it with something meaningless. Causation graphs built from this data are wrong in ways that look right. Section 7 draws the chain that does work and marks the links that do not.
3.8 timestamp¶
Declared readonly timestamp: string, ISO 8601. Written by every producer as
new Date().toISOString() at envelope construction — that is, inside the business
transaction, at the moment the outbox row is persisted, not at commit and not at publish.
Read by the audit service, which parses it back: timestamp: new Date(event.timestamp).
What breaks if it is absent or malformed. new Date(undefined) yields Invalid Date,
and inserting that into a timestamptz column throws, so the audit consumer nacks and the
message dead-letters. Nothing validates the string before that point.
There is no occurredAt, no publishedAt and no processedAt in the envelope. Section
9 lays out where each of those three times actually lives, because they do exist — just not
on the wire.
3.9 payload¶
Declared readonly payload: T, where T defaults to unknown and is narrowed per
event type by the payload interfaces in
libs/platform/event-contracts/src/lib/{trading,inventory,accounting,commission}-events.ts.
The governing convention is stated at the top of the trading contracts file: payloads are
enriched with names, not just ids, so consumers don't need to re-fetch reference data.
TradingLineItemSnapshot carries productName beside productId, unitCode beside
unitId, currencyCode beside currencyId. DealLockedPurchaseSnapshot carries
supplierName and traderName. The ADR on event-carried invoice line items extends this:
the trading.line-item.finalised payload carries the full invoice-relevant snapshot
including the FX rate snapshotted at confirmation, so accounting can build an invoice from
the event alone with no synchronous call back into trading.
That is a deliberate trade of message size for autonomy. trading.deal.locked is the
extreme case — a full snapshot of the deal with purchases, sales, haulages, overheads and
credit notes, each with their line items. It is also the reason the audit fanout is a
privacy surface: whatever is in the payload is persisted verbatim into
audit_entry.new_state.
That last point produced a real defect and a real fix. Two payloads carry raw bearer
tokens — platform.user.invited carries the invitation accept token so notification can
build the email link, and identity.password.reset-requested carries the reset token for the
same reason. The relay dual-publishes every payload byte-identically to the BC exchange
and to acme.audit-feed, so those tokens landed unredacted, at rest, in the platform-wide
audit store. The fix is an off-by-default per-eventType denylist of dotted paths:
readonly auditSecretFields?: Readonly<Record<string, readonly string[]>>;
// e.g. { 'platform.user.invited': ['payload.token'] }
buildAuditBuffer deep-clones the envelope by JSON round-trip and deletes each declared
path — from the audit copy only; the BC lane still ships the untouched buffer, because the
legitimate consumer needs the token. A later hardening reuses the same denylist in
scrubbedPublishedPayload, so that when the entry flips to PUBLISHED the stored jsonb is
rewritten without the token too. Both are no-ops when no path is configured for the event
type: buildAuditBuffer returns the original buffer by reference, and
scrubbedPublishedPayload returns undefined so the SET clause omits payload entirely.
What breaks. A payload shape change that is not additive, shipped without a version
bump, is deserialized by the old consumer as whatever it happens to be — TypeScript types
are erased at runtime and nothing validates the body. That is the failure the
versioned-routing-key ADR exists to prevent, and it is prevented only if producers actually
bump version and route to the suffixed key. See 3.3 for the state of that.
3.10 aggregateId¶
Declared readonly aggregateId?: string. Optional in the type. This field has the
sharpest producer/consumer mismatch in the envelope.
Written by exactly one producer in the entire codebase:
// apps/platform/accounting-service/.../open-month.use-case.ts
await this.eventPublisher.publish(txEm, {
eventId: uuidV7(),
eventType: 'accounting.accounting-month.opened',
...
aggregateId: accountingMonth.id,
payload,
});
Read at five production sites, three of which force it with a non-null assertion:
audit-event.consumer.ts:entityId: event.aggregateId!reporting-serviceprojection consumers for trading, accounting, inventory and commission:{ _dealId: event.aggregateId!, tenantId: event.tenantId }and equivalentsai-servicesignal consumers, which are the only readers that guard:if (!event.aggregateId || !event.tenantId) { ... }and drop the event with a structured log
The audit path is the consequential one. AuditEntry.create has no fallback
(entry._entityId = params.entityId), and the column is declared
entity_id VARCHAR(255) NOT NULL in both the entity and the migration. An event with no
aggregateId therefore produces an entry with an undefined required property, the flush
fails, handleEvent throws, and setupRabbitConsumer's catch nacks the message without
requeue to audit-service.events.dlx and on to audit-service.events.dlq.
Unverified: only the code path was read; no live audit table was queried, so what
fraction of events actually dead-letters in a running environment is unknown. What is
verifiable is that the code has no guard, that seventeen of eighteen producers omit the
field, and that every fixture in audit-event.consumer.spec.ts supplies
aggregateId: 'deal-123' — so the unit suite never exercises the case that production
overwhelmingly produces.
What breaks. For audit, the entry is not written and the event dead-letters. For the
reporting projections, the non-null assertion means undefined flows into a findOne
predicate — matching nothing, or upserting a row keyed on undefined. For AI signal
ingestion, the event is explicitly dropped and logged, which is the only correct handling of
the three.
The right fix is either to make aggregateId required in the interface and backfill all
eighteen producers, or to make every consumer fall back to a payload id. Neither has
happened.
3.11 id and type — aliases that do not exist¶
Declared as optional, with the comment "Convenience aliases populated by outbox relay serialization — consumers may use either form."
This comment is false. The relay's serialization is one line:
It writes the stored envelope verbatim. Nothing anywhere in libs/platform/event-bus or in
any service assigns id or type on an envelope, and no consumer reads either. A consumer
that trusted the comment and read event.id would get undefined for every message on the
bus.
They are dead fields. They should be deleted from the interface; until they are, treat the comment as a trap.
3.12 The optional audit and notification metadata¶
Four fields were added later and are documented as backward compatible. The contracts test
suite (domain-event.spec.ts) exists specifically to pin that: it asserts a base event
without any of them is valid, that each is accepted individually, that userEmail accepts
null (GDPR — the audit service prefers a lookup table over storing the address), that
recipientUserIds accepts an empty array, and that all four are undefined when omitted.
userEmail?: string— notification routing only. Trading is the only producer that sets it, fromEventContext.userEmail. Audit reads it into a nullable column but the field's own comment directs the audit service to use a lookup table instead, for GDPR erasure.recipientUserIds?: string[]— target users for notification. OnlyLockDealUseCasesets it, from the trader ids extracted off the deal, and only when the list is non-empty (...(traderIds.length > 0 && { recipientUserIds: traderIds })).ipAddress?: string— the client IP the gateway resolved fromx-forwarded-for, falling back to the socket address, injected asx-ip-address. Trading and inventory thread it through theirEventContext/sourceEvent;AuthEventPublishersets it on login, logout and account-locked. Audit persists it.changedFields?: string[]— which fields this event changed, computed by the producer.LockDealUseCasewrites['status', 'lockedAt', 'lockedGrossProfit']; inventory writes['availableQuantity']or['reservedQuantity']; commission writes['status', 'amount', 'period']. Audit persists it, nullable.
Every one of the four uses conditional spread at the construction site, so an absent value
means the key is absent from the JSON entirely rather than present-and-null. That matters
for the audit ?? null reads, which cannot distinguish the two anyway.
4. Who stamps what, and when¶
The envelope is not assembled in one place. Four components contribute, at four different moments, and only the first two touch the envelope itself — the rest wrap it.
flowchart TD
subgraph EDGE["Gateway — request time"]
G1["Ingress hook strips client-supplied x-correlation-id"]
G2["JWT guard mints x-correlation-id, x-tenant-id, x-user-id, x-ip-address"]
G1 --> G2
end
subgraph SVC["Producing service — inside the business transaction"]
S1["Guard rebuilds RequestUser from headers"]
S2["Use case mutates the aggregate"]
S3["Publisher stamps eventId, eventType, version, tenantId, userId,<br/>correlationId, causationId, timestamp, payload, optional metadata"]
S4["EventPublisher persists OutboxEntry:<br/>entryType, event_type, routing_key, payload jsonb, status PENDING"]
S1 --> S2 --> S3 --> S4
end
subgraph DB["PostgreSQL"]
D1["COMMIT — aggregate row and outbox row land together"]
end
subgraph RLY["OutboxRelay — background poller"]
R1["Phase 1 claim: advisory lock, PENDING to PUBLISHING"]
R2["Phase 2 read version from payload, validate against routing key"]
R3["Stamp transport: traceparent, contentType, messageId, persistent"]
R4["Publish to acme.bc topic exchange"]
R5["Publish redacted copy to acme.audit-feed fanout"]
R6["Phase 3 write back PUBLISHED and published_at"]
R1 --> R2 --> R3 --> R4 --> R5 --> R6
end
subgraph CONS["Consumer"]
C1["Parse JSON body back into DomainEvent"]
C2["Read tenantId, seed tenant filter, enter TenantContext"]
C3["withInbox checks consumer plus eventId"]
C4["Handler applies effect, records inbox row, acks"]
C1 --> C2 --> C3 --> C4
end
G2 --> S1
S4 --> D1
D1 --> R1
R4 --> C1
R5 --> AUD["audit-service"]
What the diagram shows, and the three things worth reading off it.
The envelope is frozen at commit. Every envelope field is written in the SVC box, inside
the business transaction, before the aggregate is durable. Nothing downstream edits the
envelope — the relay adds transport metadata around it and, when a secret denylist applies,
subtracts from a copy. The single exception is the post-publish
scrubbedPublishedPayload rewrite, which mutates the stored row after the message has
already left.
The gateway owns correlation identity, but only for the first hop. The ingress hook and the JWT guard together guarantee a downstream service cannot be told what its correlation id is by an external caller. What the platform does not have is a mechanism to carry that id into an event chain — no AsyncLocalStorage correlation context, no automatic inheritance. Each producer re-derives it, which is why section 3.6's table looks the way it does.
The relay stamps nothing on the envelope. This is deliberate and it is the reason
eventId must be producer-supplied. If the relay generated ids, a replayed row would get a
new one and every downstream inbox would treat the replay as a new event.
5. The transport layer — AMQP properties and headers¶
The relay publishes each entry twice, on two code paths, with different options. The difference is not documented anywhere and it is observable at the broker.
BC lane. publishToChannel calls publishToBoth, passing its transport metadata as the
headers argument:
await this.withConfirmTimeout(
publishToBoth(
channel,
exchange,
entry.eventType,
version,
transitionVersion,
buffer,
{ ...headers, contentType: "application/json", messageId: entry.id }
),
contextLabel
);
publishToBoth then hands that whole object to amqplib as headers:
So on the BC lane, contentType and messageId are AMQP application headers, not AMQP
basic properties. The content-type basic property still gets set, because the connection
wrapper merges its own defaults:
but message-id does not. It exists only as a header named messageId.
Audit lane. publishOnce builds an amqplib options object properly:
const auditOptions: Record<string, unknown> = {
persistent: true,
contentType: "application/json",
messageId: entry.id,
headers,
};
so on the fanout copy, message-id is a basic property.
The net effect, per message:
| Transport slot | BC lane | Audit lane | Set from |
|---|---|---|---|
delivery-mode |
2 (persistent) | 2 (persistent) | persistent: true |
content-type |
application/json |
application/json |
connection default / explicit |
message-id |
not set | OutboxEntry.id |
— |
header messageId |
OutboxEntry.id |
absent | relay |
header contentType |
application/json |
absent | relay |
header traceparent |
W3C context if OTel present | same | injectTraceContext() |
correlation-id |
not set | not set | — |
app-id, type, timestamp |
not set | not set | — |
| routing key | canonical .vN key, plus legacy base key during a transition window |
the entry's stored routingKey, for traceability only — the fanout ignores it |
relay |
Three practical consequences.
First, the AMQP message-id, where it is set at all, is the outbox row id, not the
eventId. A consumer that deduplicated on the broker's message id rather than on
event.eventId would break the moment a row was re-created for the same domain event. Every
consumer in this codebase correctly reads event.eventId out of the body; the trap is there
for the next one.
Second, the envelope's correlationId is never mirrored into the AMQP correlation-id
property. Broker-level tooling — the management UI, a shovel, a firehose tracer — cannot
see it. Correlation is a body-level concept here, full stop.
Third, trace context is a header, and it is optional. injectTraceContext resolves
@opentelemetry/api through a lazy require inside a try/catch and returns {} if the
package is unavailable, so the relay degrades to untraced publishing rather than crashing.
The symmetric static OutboxRelay.extractTraceContext(headers) filters incoming headers to
string values only — RabbitMQ headers can be Buffers or numbers — before handing them to the
propagator.
The dead-letter path adds two more headers, both defined as exported constants:
| Header | Carries |
|---|---|
x-acme-original-payload |
the entire original envelope, JSON-stringified, so a recovery tool can replay it verbatim once the producer bug is fixed |
x-acme-actual-version-raw |
the raw version value stringified, present only when it was non-numeric or out of range — RabbitMQ headers cannot hold arbitrary JSON |
and the DLX message body is not the event at all, but a structured envelope DLX consumers can rely on:
export interface VersionMismatchDlxPayload {
readonly originalRoutingKey: string;
readonly expectedVersion: number;
readonly actualVersion: number;
readonly eventId: string; // NOTE: this is entry.id, the row id
readonly reason: "VERSION_BINDING_MISMATCH";
}
Note the field named eventId there is populated with entry.id — the outbox row id, not
the domain event id. Two different identifiers under one name, one hop apart.
6. The identity triple¶
At-least-once delivery is a broker guarantee that cannot be turned off. Effectively-once
processing is built on top of it, by three cooperating identities. All three derive from
eventId, and each covers a failure the others do not.
erDiagram
OUTBOX_ENTRY {
uuid id PK
varchar event_type
varchar routing_key
jsonb payload
varchar status
int retry_count
timestamptz created_at
timestamptz published_at
}
PROCESSED_EVENT {
text consumer PK
text event_id PK
timestamptz processed_at
}
STOCK_MOVEMENT {
uuid id PK
varchar event_id UK
uuid position_id
numeric quantity
}
AUDIT_ENTRY {
uuid id PK
varchar source_event_id
varchar entity_id
jsonb new_state
varchar correlation_id
varchar causation_id
}
OUTBOX_ENTRY ||--o{ PROCESSED_EVENT : "payload eventId recorded once per consumer"
OUTBOX_ENTRY ||--o{ STOCK_MOVEMENT : "eventId colon lineItemId per effect"
OUTBOX_ENTRY ||--o{ AUDIT_ENTRY : "eventId as source_event_id"
What the diagram shows. One outbox row fans out into three different durable records
downstream, each keyed on a different derivation of the same eventId.
Layer 1 — the inbox, keyed (consumer, event_id). processed_event deliberately has a
composite primary key with the consumer name in it, so two different consumers of the same
event each get to process it once. The consumer name is a constant, e.g.
INVENTORY_TRADING_CONSUMER = 'inventory-trading-event-consumer'. The wrapper is small
enough to quote whole:
export async function withInbox(
em,
consumer,
eventId,
eventType,
apply
): Promise<boolean> {
const inbox = new MikroOrmProcessedEventRepository(em);
if (await inbox.hasProcessed(consumer, eventId)) {
recordConsumerRedelivery(consumer, eventType);
return false;
}
await apply();
await inbox.recordOnce(consumer, eventId);
return true;
}
The ordering is the whole design. The inbox row is recorded after a successful
apply(), never before. Inventory's stock handlers persist through per-operation forked
EntityManagers, so a single caller transaction cannot span both the inbox insert and every
stock write. Recording after success keeps the inbox a truthful "already-applied" ledger: a
crash between apply() and the insert simply re-runs apply() on redelivery, which is safe
because layer 2 makes every handler independently idempotent. Recording before apply()
would be the classic anti-pattern — mark processed, then lose the state change.
The repository behind it wins the exactly-one-winner race twice over: a synchronous
in-process Set reservation that closes the window between two concurrent calls on the same
instance before the first await, and the database composite primary key, whose violation
is caught and reported as a duplicate rather than rethrown:
try {
await this.em.flush();
} catch (error) {
if (error instanceof UniqueConstraintViolationException) return false;
throw error;
}
Both of its reads pass { filters: false }, because ProcessedEvent is deliberately not a
tenant entity — the dedup identity is (consumer, event_id), the columns carry no
tenant-owned data — and the fail-closed global filter would otherwise throw on a consumer
path that has no tenant context.
Layer 2 — the per-effect key. The inbox is coarse: one row per event. But one event can
legitimately produce many effects, and the effect table needs its own guard for the window
where apply() half-succeeded. Inventory composes it:
// Per-line-item idempotency: composite key = eventId:lineItemId
const movementEventId = `${event.eventId}:${lineItem.lineItemId}`;
stock_movement.event_id is varchar(255) under the unique index ix_movement_event. Note
the uniqueness is global, not per-tenant — the index is declared on _eventId alone. With
UUID v7 event ids that is safe, and it is what allows the column to be varchar rather than
uuid.
Layer 3 — the observability counter. Every dedup hit increments
acme_inventory_consumer_redeliveries_total, labelled by consumer and event_type. It is
a deliberately dependency-free in-process Map — the services do not yet vendor a Prometheus
client — with snapshotConsumerRedeliveries() waiting for the exposition endpoint to land.
Without this counter, dedup is invisible: a broker retry storm and a healthy system look
identical from the outside.
Why the envelope, not the payload, is the contract boundary. A consumer needs three
things before it can even look at the business data: what tenant it is in, whether it has
seen this before, and what shape the body is. All three live on the envelope —
tenantId, eventId, version — and all three are read before payload is touched. That
ordering is visible in every consumer:
const event = JSON.parse(msg.content.toString()) as DomainEvent<T>;
const tenantId = event.payload?.tenantId ?? event.tenantId; // envelope
if (!tenantId) {
channel.nack(msg, false, false);
return;
} // envelope
await TenantContext.run({ tenantId }, () => this.dispatch(event));
// dispatch: withInbox(em, CONSUMER, event.eventId, event.eventType, ...) // envelope
// only then: switch (event.eventType) { case ...: handler.handle(typed) } // envelope
The payload is what the domain agreed on. The envelope is what the platform agreed on, and it is uniform across every bounded context — which is why a completely BC-agnostic consumer like the audit service can bind one fanout queue and process everything without knowing a single payload type.
7. Correlation and causation across three services¶
The intended chain is: a command produces an event, that event causes a second event in another service, which causes a third. Correlation stays constant across the whole chain; causation points one link back.
sequenceDiagram
autonumber
participant CL as Client
participant GW as Gateway
participant TR as trading-service
participant MQ as RabbitMQ
participant IV as inventory-service
participant AU as audit-service
CL->>GW: POST receipt a purchase
Note over GW: strips any inbound x-correlation-id,<br/>mints corr = 00000000-0000-0000-0000-000000000009
GW->>TR: forwards with x-correlation-id, x-tenant-id, x-user-id, x-ip-address
TR->>TR: BEGIN — mutate purchase aggregate
Note over TR: envelope A — eventId evtA,<br/>correlationId corr, causationId FRESH UUID
TR->>TR: INSERT outbox_entry status PENDING
TR->>TR: COMMIT
TR->>MQ: relay publishes trading.purchase.receipted
MQ->>IV: delivery on inventory queue
MQ->>AU: fanout copy on acme.audit-feed
IV->>IV: withInbox checks consumer plus evtA
IV->>IV: create stock movements keyed evtA colon lineItemId
Note over IV: envelope B — eventId evtB,<br/>correlationId corr inherited,<br/>causationId evtA — the real link
IV->>IV: INSERT outbox_entry status PENDING
IV->>MQ: relay publishes inventory.stock.updated
MQ->>AU: fanout copy
AU->>AU: row for evtA, correlation corr, causation FRESH UUID
AU->>AU: row for evtB, correlation corr, causation evtA
What the diagram shows, and where it is honest. The correlation id survives the whole chain, because trading takes it off the request and inventory inherits it from the inbound event. That part works.
The causation link works on exactly one hop — the inventory one, drawn as causationId evtA.
The trading hop is drawn as causationId FRESH UUID because that is literally what
TradingEventPublisher writes:
There is no parent event for a command-originated event, so a correct implementation would
point causationId at the command — a request id, or the correlation id, or nothing at all
if the field were optional. Generating a fresh UUID produces a graph node with an edge to a
vertex that does not exist. Rebuild a causation tree from audit_entry and every
trading-originated branch is rooted at a phantom.
Also worth noting what the diagram does not show: no synchronous call from inventory back
to trading. Inventory has everything it needs because the payload is name-enriched
(TradingLineItemSnapshot carries productName, unitCode, currencyCode). That is the
autonomy the enrichment convention buys, and it is why the deal-locked payload is as large
as it is.
One more honest caveat on this diagram: trading-service's leg is drawn as designed, not as verified running. Section 10, item 1 explains why.
8. An annotated wire example¶
This is the JSON body of a trading.purchase.receipted message as it would appear on
acme.trading, with every value sanitized. The shape is derived from DomainEvent<T> plus
PurchaseReceiptedEventPayload and the construction site in TradingEventPublisher.
{
"eventId": "00000000-0000-0000-0000-000000000001",
"eventType": "trading.purchase.receipted",
"version": 1,
"tenantId": "00000000-0000-0000-0000-000000000002",
"userId": "00000000-0000-0000-0000-000000000003",
"correlationId": "00000000-0000-0000-0000-000000000004",
"causationId": "00000000-0000-0000-0000-000000000005",
"timestamp": "2026-07-30T09:14:22.481Z",
"ipAddress": "203.0.113.17",
"changedFields": ["status", "receiptedAt"],
"payload": {
"purchaseId": "00000000-0000-0000-0000-000000000006",
"dealId": "00000000-0000-0000-0000-000000000007",
"tenantId": "00000000-0000-0000-0000-000000000002",
"receiptedAt": "2026-07-30T09:14:22.400Z",
"supplierId": "00000000-0000-0000-0000-000000000008",
"lineItems": [
{
"lineItemId": "00000000-0000-0000-0000-000000000009",
"productId": "00000000-0000-0000-0000-00000000000a",
"productName": "Product Alpha",
"quantity": "1000.0000",
"unitId": "00000000-0000-0000-0000-00000000000b",
"unitCode": "KG",
"price": "1.2500",
"priceUnitId": "00000000-0000-0000-0000-00000000000b",
"currencyId": "00000000-0000-0000-0000-00000000000c",
"currencyCode": "GBP",
"countryOfOrigin": "GB"
}
]
}
}
| Line | Field | Commentary |
|---|---|---|
| 2 | eventId |
UUID v7 from v7(), minted at envelope construction inside the business transaction. Becomes processed_event.event_id, and with the line-item id appended becomes stock_movement.event_id. Reuse it for a second, different receipt and inventory silently swallows the second one. |
| 3 | eventType |
First segment trading is what makes deriveExchange return acme.trading. Also the row's event_type and routing_key columns, the consumer dispatch discriminant, and — because it starts with trading. — the reason EventHandlerExplorer will refuse to auto-wire any handler for it without FD sanction. |
| 4 | version |
Read from the body, not from the row. Absent would default to 1. Because the routing key is the unsuffixed trading.purchase.receipted, the relay expects 1 and finds 1 — this message passes. Set it to 2 without changing the routing key and this message dead-letters instead. |
| 5 | tenantId |
The only thing that lets the consumer query anything. Consumers read payload.tenantId ?? event.tenantId, so line 16 actually wins here — they agree, and nothing checks that they do. |
| 6 | userId |
The operator who receipted. Would be the literal string "system" for a background-originated event, so do not parse it as a UUID. |
| 7 | correlationId |
Minted by the gateway with randomUUID() and injected as x-correlation-id after the ingress hook stripped any client copy. TradingEventPublisher reads it off EventContext. Inventory will inherit this exact value onto its downstream event. |
| 8 | causationId |
A fresh v7() that points at nothing — see 3.7. In a correctly-chained event (anything inventory emits) this would be the parent's eventId. |
| 9 | timestamp |
new Date().toISOString() at construction time, so it precedes both COMMIT and publish. Audit parses it back into timestamptz. |
| 10 | ipAddress |
From the gateway's x-ip-address, resolved from the first x-forwarded-for entry or the socket address. Present only because the producer threaded EventContext.ipAddress through. |
| 11 | changedFields |
Producer-computed. Present only because the array is non-empty — the construction site uses a conditional spread, so an empty list means the key is absent, not []. |
| 12–29 | payload |
The typed PurchaseReceiptedEventPayload. Note it is delivered verbatim to acme.audit-feed as well and persisted into audit_entry.new_state, so anything secret here needs a denylist entry. |
| 16 | payload.tenantId |
Redundant with line 5 by convention, and the value consumers actually prefer. |
| 22–24 | productName, unitCode |
The name-enrichment convention: inventory never calls back into trading to resolve them. |
| 21, 24 | quantity, price |
Decimal strings, never JSON numbers. Money and quantities are Big in process, numeric in the database and strings on the wire, so no value ever passes through an IEEE-754 double. |
| 27 | currencyCode |
Shipped alongside currencyId for the same reason as productName. |
Fields absent from this message and why: aggregateId (no trading producer sets it — see
3.10); id and type (nothing ever sets them — 3.11); userEmail and recipientUserIds
(set only where a notification needs them, e.g. trading.deal.locked carries the trader
ids). Their absence is not an error; the interface marks all of them optional.
9. The three times, and where each one lives¶
The envelope has one timestamp. The system distinguishes three, and only one of them is on the wire.
| Time | Meaning | Where it lives | Set by |
|---|---|---|---|
| occurred-at | when the domain fact happened | DomainEvent.timestamp, ISO 8601 string, on the wire |
producer, at envelope construction, inside the transaction |
| published-at | when the broker confirmed the publish | outbox_entry.published_at, timestamptz, producer-side only |
relay Phase 3, entry.publishedAt = new Date() |
| processed-at | when a given consumer applied it | processed_event.processed_at, timestamptz, consumer-side only |
inbox row, onCreate: () => new Date() |
Nothing propagates published_at or processed_at across a boundary. A consumer cannot tell
how long a message sat in the outbox before publication, and a producer cannot tell whether
anyone consumed it. End-to-end latency is only observable by joining OTel spans through the
traceparent header — which is why that header degrading silently to absent when
@opentelemetry/api cannot be resolved is worth knowing about.
There is one further subtlety on published_at. When the reaper flips a long-stuck
PUBLISHING row to FAILED, it also nulls publishedAt, so a FAILED row never carries a
publish time it did not earn.
The outbox ADR describes the envelope as carrying occurredAt: Date. The shipped interface has
timestamp: string. That is one of several divergences catalogued next.
10. Verified defects, drift and dead code¶
These are as-built observations from the branch that was read, not aspirations. Each names the file that proves it.
-
Four services construct envelopes for an entity their ORM does not know about.
EventPublisher.publishends inem.persist(new OutboxEntry()), and MikroORM in this codebase discovers entities from an explicit array only —createMikroOrmConfigpassesentitiesstraight through, with no glob discovery, andServiceModule.forRootdoes not append anything.OutboxEntryappears in the entity arrays of auth-, inventory-, tenant- and user-service only. It does not appear in trading-, accounting-, commission- or document-service, all four of which constructDomainEventenvelopes and callEventPublisher.publish. Inventory's own outbox migration documents the resulting error verbatim: "registering OutboxEntry fixes the 'Metadata for entity OutboxEntry not found' error". Trading-service registersProcessedEvent,ParkedMessageandIdempotencyKey— the inbound-integration tables — but not the outbound one. Unverified: none of these services were run, so the exact runtime exception text — and whether some path avoids the persist — is unconfirmed. -
The one v2 producer cannot reach its exchange.
LockDealUseCaseemitsversion: 2whileEventPublisherhardcodesroutingKey = eventType, so the relay's version check fails and routes the entry toacme.trading.dlxwith reasonVERSION_BINDING_MISMATCH, marking itPUBLISHED. The DLX has no queue bound for that routing key, so the copy is not retained either.buildVersionedRoutingKeyexists and would fix this in one line; nothing on the write path calls it. -
aggregateIdis written by one producer and read by five consumers, three of them with!.audit_entry.entity_idisNOT NULLwith no fallback inAuditEntry.create. The audit consumer's unit fixtures all supplyaggregateId, so the suite never covers the case production overwhelmingly produces. Theai-serviceconsumers are the only readers that guard. -
idandtypeare documented as "populated by outbox relay serialization" and are populated by nothing. The relay's serialization isJSON.stringify(entry.payload). -
causationIdis meaningless in four of eight producers.TradingEventPublisherwrites a freshv7(); three others write the correlation id back into it; accounting, commission andLockDealUseCasewrite an aggregate id where an event id is specified.AuthEventPublishercarries an explicit TODO acknowledging it. -
correlationIdmeans three different things. A request id in trading and inventory, a fresh per-event UUID in auth, and a business identifier (dealId,invoiceId) in accounting, commission and document. There is no request-scoped correlation context to propagate it automatically. -
message-idis the outbox row id on the audit lane and is not set at all on the BC lane, wheremessageIdandcontentTypeend up as application headers because the relay passes them intopublishToBoth'sheadersparameter.correlation-id,app-id,typeandtimestampbasic properties are never set on either lane. -
The outbox table is shared, and the advisory lock is per service.
platform_outbox.outbox_entrylives in a shared schema of a shared database, owned by a group role every service role joins. Phase 1 claims rows withfind(OutboxEntry, { status: PENDING })— no bounded-context discriminator — underpg_try_advisory_xact_lock(advisoryLockId), where every service's config schema declares a different default lock id and bundle values override with different ids again. Two relay-enabled services therefore both acquire their own lock and can both claim the same PENDING rows.EventBusModuleeven encodes the assumption that this cannot happen: "this static<exchangeName>.dlxequalsdlxRoute()'s runtime target because a relay-enabled service only ever emits its own bounded context's events (one-BC-per-outbox)" — which holds only under per-BC databases, a topology the database-hardening ADR defers to a future extraction. Unverified: no duplicate delivery caused by this was observed, only the code paths that permit it. -
The outbox ADR and the shipped envelope disagree in four ways. The ADR specifies
aggregateType,occurredAt: Date, and a nestedmetadata: { tenantId, correlationId, causationId, userId }; the shipped interface is flat, usestimestamp: string, and has noaggregateType. The ADR also describes a consumer-sideprocessed_eventstable keyed onevent_idalone; the shippedprocessed_eventis keyed on(consumer, event_id). And the ADR's replay recipe — "re-publish events by resettingpublished_atto null" — would do nothing, because the relay's claim query filters onstatus = PENDING, not onpublished_at IS NULL. The code is the source of truth on the wire. -
The polling interval doubles the outbox ADR's estimate. That ADR says the relay adds "typically 100–500ms";
OutboxRelayConfig.pollIntervalMsdefaults to 1000.
Where this connects¶
- Its survey doc:
platform/event-catalog.md— the event taxonomy, exchange and routing-key grammar, the versioned-key mechanism end to end, and the per-BC broker topology this document assumes. - Sibling patterns:
platform/integration-patterns.md— the outbox, inbox, idempotency-key and parked-message trio as patterns, plus the saga and projection flows that consume these envelopes. - The transport:
backend/05-messaging.md— queue, DLX and DLQ naming, relay phase mechanics, consumer reconnect discipline, and how users, permissions and topology are provisioned declaratively. - Where the row lives:
backend/03-data-architecture.md— per-BC schema isolation, the sharedplatform_outboxschema, the fail-closed tenant filter, and RLS. - Why
tenantIdis load-bearing:backend/04-authn-authz.md— the gateway header contract that producesx-correlation-id,x-tenant-id,x-user-idandx-ip-address, and the ingress strip hook that makes them trustworthy. - In this folder:
02-event-families.md— the counted index of every event, the exchange and routing key that carry it, and which consumer actually receives it;03-the-life-of-one-event.md— one message traced hop by hop from the browser click to the last downstream side effect;04-event-evolution.md— how to change a payload without breaking a consumer, and which changes need a version bump;05-choreography-decisions.md— why there is no orchestrator, and where the choreography is currently broken. - Deep-dive siblings:
../rabbitmq/01-topology.mdand../rabbitmq/04-failure-atlas.mdfor broker-level behaviour and what an operator does when it breaks;../multi-tenancy/03-propagation.mdand../multi-tenancy/04-enforcement.mdfor how thetenantIdon this envelope becomes aSET LOCALand a filter predicate. - Decisions referenced here: per-bounded-context schema isolation; unified broker messaging; the transactional outbox; database hardening and the per-service advisory-lock allocation; the audit-feed fanout; platform scope; versioned routing keys; the AI signal event taxonomy; the deal-activity read model; event-carried invoice line items; and the inbox, idempotency-key and parked-message tables.