The Envelope
Last updated:
The single, frozen definition of the bytes that travel on the queue. Every SDK, in every language, MUST produce and consume exactly this shape.
Status: Authoritative · Frozen ·
schema_version: 1
Canonical envelope
{
"job": "urn:babel:orders:created",
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
"data": {
"order_id": 1042,
"amount": 99.90
},
"meta": {
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
"queue": "default",
"lang": "php",
"schema_version": 1,
"created_at": 1749132727000
},
"attempts": 0
}
Top-level key order is not significant (JSON objects are unordered), but producers SHOULD emit keys in the order above for readable logs.
schema_version: 1). Five required top-level fields: job is the message identity, a URN and never a class name; trace_id is the correlation id, forwarded unchanged on every hop; data is the pure JSON business payload; meta is producer metadata set once and immutable (id, queue, lang, schema_version, created_at); attempts is the transport retry counter and the only field that changes during the message's life.Field reference
| Field | Type | Required | Mutable | Description |
|---|---|---|---|---|
job |
string | ✅ | no | The message URN — its language-agnostic identity. See URN naming. The canonical field name is job; consumers MUST also accept urn as an inbound alias. Never a PHP/Java/… class name. |
trace_id |
string (UUID) | ✅ | no | Cross-service correlation id. Generated by the original producer; preserved and forwarded unchanged by every SDK across every hop. Enables end-to-end distributed tracing. |
data |
object | ✅ | no | The business payload — a pure, JSON-encodable key/value object. No language-specific types (no PHP objects, closures, resources; no binary without base64). Numbers follow the data rules. |
meta |
object | ✅ | no¹ | Producer-set descriptive metadata. Immutable after production. See the meta block. |
attempts |
integer | ✅ | yes | Transport-level delivery/retry counter, starting at 0. Mutated by the broker/worker on each (re)delivery. Deliberately top-level, kept out of the immutable meta block. |
¹ meta is conceptually immutable end-to-end. The only counter that legitimately
changes during a message’s life is top-level attempts — it is transport state,
not producer metadata.
The meta block
| Field | Type | Required | Description |
|---|---|---|---|
id |
string (UUID) | ✅ | Unique id for this specific message. Distinct from trace_id. |
queue |
string | ✅ | The logical queue name the message was produced onto (not the broker key). |
lang |
string (enum) | ✅ | Producer language tag — one of php, go, python, java, dotnet, node. For observability and debugging. |
schema_version |
integer | ✅ | Envelope schema version. Currently 1. Consumers MUST reject (or quarantine) versions they do not understand. |
created_at |
integer | ✅ | Production time as Unix epoch milliseconds, UTC (milliseconds, not seconds). |
Producers MAY add extra meta.* keys for their own observability, but consumers
MUST ignore unknown meta keys (forward-compatibility), and those keys MUST NOT be
required for correct routing or processing. Unknown meta keys MUST be preserved when
a message is re-emitted — see Extension keys.
The lang enum constrains producers only. A consumer MUST accept an unknown meta.lang
value (a future or community SDK) and forward it unchanged — never reject or rewrite it.
id vs trace_id — do not conflate
meta.id |
trace_id |
|
|---|---|---|
| Scope | one message | one causal chain (may span many messages/services) |
| Generated by | the producing SDK, per message | the first producer in the chain; reused thereafter |
| Lifetime | dies with the message | propagates across every hop |
| Use | dedupe, ack/nack, logs for one message | end-to-end tracing/observability |
When a consumer produces a downstream message while handling one, it SHOULD copy the
inbound trace_id onto the new envelope and mint a new meta.id.
Cross-language data rules
Because the same data is decoded by six languages, these are part of the contract,
not implementation details:
- Encoding. UTF-8 JSON only — no trailing commas, no comments, no
NaN/Infinity. The reference encoders emit unescaped unicode and slashes so output is byte-identical across SDKs. - Numbers. Integers must fit signed 64-bit; do not exceed
2^53 − 1if any consumer is JavaScript/Node unless carried as a string. Floats are IEEE-754 doubles — never put currency or exact-decimal values in floats; use integer minor units (e.g. cents) or strings. (The example uses99.90for readability only.) - Time.
meta.created_atis Unix epoch milliseconds (UTC). Time values insidedataSHOULD be Unix ms (integer) or RFC 3339 / ISO 8601 UTC strings — agreed per URN, never a locale-formatted string. - Binary. Raw bytes are not allowed in JSON. Base64-encode and document the field per URN.
- Booleans / null. Use JSON
true/false/null— never0/1or"true".
Producer rules
A conformant producer MUST:
- Set
jobto a non-empty URN. An empty URN is a programming error and MUST raise. - Set
trace_idto a UUID — reuse an inbound one if continuing a trace, otherwise generate a new v4 UUID. - Emit
dataas a pure JSON object (see the data rules above). - Populate every required
metafield —meta.idunique per message,meta.langmatching the producing language,meta.schema_version = 1,meta.created_atin Unix ms. - Initialize top-level
attemptsto0. - Encode as UTF-8 JSON and publish via the broker binding.
Consumer rules
A conformant consumer MUST:
- Read the URN from
job, acceptingurnas an alias ifjobis absent. - Reject/quarantine any envelope whose
meta.schema_versionit does not support. - Route to the handler mapped to the URN. If none is mapped, apply the configured
unknown-URN strategy (
fail·delete·release·dead_letter). - Preserve
trace_idfor logging and for any downstream messages. - Treat
metaanddataas read-only; only the broker/worker mutatesattempts. - Tolerate unknown extra keys (forward-compatibility) — never hard-fail on them.
- On re-emit, preserve every unknown
meta.*key and every unknown top-level key unchanged (retry, dead-letter move, redrive, byte-level transfer). A message a handler produces as a consequence of processing is a new message and is out of scope.
Extension keys
The envelope is frozen, but producers and vendors can still carry their own data in it. These rules (ADR-0031) say how extra keys are named, written, preserved and rejected.
Naming. Extension keys SHOULD be snake_case, and vendor-specific keys SHOULD live under a
single vendor block, meta.ext.<vendor>, so they never collide with a future core key:
"meta": {
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567", "queue": "orders", "lang": "go",
"schema_version": 1, "created_at": 1749132727000,
"ext": { "acme": { "tier": "gold" } }
}
Writing. Every SDK core has a producer-side API for extra meta.* keys — Go WithMeta,
Python / PHP / .NET a meta argument, Node MakeOptions.meta, Java a Map meta overload (see each
SDK’s Producing messages page). The entries sit next to the core meta keys.
Preserving (MUST). On re-emit an SDK MUST preserve every unknown meta.* key and every
unknown top-level key unchanged. “Re-emit” means re-publishing the same message (same
meta.id, meta.queue and other core meta keys): a retry, a dead-letter move, a redrive, or a
byte-level transfer to another queue or broker. A message a handler produces as a consequence of
processing is a new message and is out of scope.
Rejecting at encode. An SDK rejects the message — an error in Go, an exception in the other
SDKs — instead of silently dropping a key, when it contains:
- a key that collides with a core
metakey (id,queue,lang,schema_version,created_at); - a forbidden key from the table below (
timestamp,urn,meta.max_retries,meta.attempts,meta.source,meta.ts); - the same extension key supplied twice (expressible only where the API takes key/value options,
such as Go
WithMeta).
Dropping at decode. A decoder stays lenient: it never hard-fails on a forbidden key, it drops the key and warns. Because the decoded message no longer carries it, re-emitting that message is never rejected.
urn is resolved on decode. If job is missing, empty or only ASCII whitespace (space,
\t, \n, \v, \f, \r), the decoder reads the URN from the inbound urn alias and the
message carries it as job. If a non-blank job and a urn arrive together, job wins and urn
is dropped with a warning. A re-emit writes job and never urn.
Key matching is exact. Known, core and forbidden keys are matched byte-wise and
case-sensitively. TS, Attempts and JOB are not ts, attempts and job: they are unknown
keys, preserved on re-emit.
Forbidden fields
These appeared in early drafts and are not part of the contract. Do not emit or rely on them:
| Forbidden field | Use instead |
|---|---|
timestamp (top-level) |
meta.created_at (Unix ms) |
meta.max_retries |
consumer-side config / per-URN policy |
meta.attempts |
top-level attempts |
meta.source |
meta.lang |
meta.ts |
meta.created_at |
urn as the canonical producer field |
job (with urn accepted only as an inbound alias) |
Producers MUST NOT emit the timestamp and meta.* keys above. A consumer that receives one MUST still
decode the message, MUST surface a warning, and MUST drop the key when it decodes it — old
producers keep working, but the key never travels further. An encoder rejects a message that still
carries one (see Extension keys).
Optional dead_letter block
Messages sitting on a dead-letter queue carry one extra, optional top-level field,
dead_letter, describing why they failed. It appears only on DLQ messages — never
on a normal queue — and is additive, so the envelope stays at schema_version: 1.
Normal consumers ignore it.
"dead_letter": {
"reason": "failed",
"error": "Payment gateway timeout",
"exception": "App\\Exceptions\\GatewayTimeout",
"failed_at": 1749132730000,
"original_queue": "orders",
"attempts": 3,
"lang": "php"
}
Change control
- Any change to this envelope is architecturally significant and needs an ADR.
- Renaming, removing, retyping or repurposing a field → bump
schema_versionand provide a migration/compat path for older consumers. - Adding an optional, ignorable field needs an ADR and an explicit version decision (not necessarily a bump).
Continue to URN naming.