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.

Anatomy of the BabelQueue envelope A JSON envelope with its five required top-level fields drawn as labelled boxes. The caption below gives the full description. JSON envelope on the wire job "urn:babel:orders:created" Identity a URN, never a class name trace_id "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b" Correlation forwarded unchanged on every hop data { "order_id": 1042, … } Payload pure JSON, no language-specific types meta idqueuelangschema_version: 1created_at Producer metadata set once, immutable schema_version 1 is frozen attempts 0 Transport state the only field that changes in flight
Anatomy of the envelope (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 − 1 if 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 uses 99.90 for readability only.)
  • Time. meta.created_at is Unix epoch milliseconds (UTC). Time values inside data SHOULD 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 — never 0/1 or "true".

Producer rules

A conformant producer MUST:

  1. Set job to a non-empty URN. An empty URN is a programming error and MUST raise.
  2. Set trace_id to a UUID — reuse an inbound one if continuing a trace, otherwise generate a new v4 UUID.
  3. Emit data as a pure JSON object (see the data rules above).
  4. Populate every required meta field — meta.id unique per message, meta.lang matching the producing language, meta.schema_version = 1, meta.created_at in Unix ms.
  5. Initialize top-level attempts to 0.
  6. Encode as UTF-8 JSON and publish via the broker binding.

Consumer rules

A conformant consumer MUST:

  1. Read the URN from job, accepting urn as an alias if job is absent.
  2. Reject/quarantine any envelope whose meta.schema_version it does not support.
  3. Route to the handler mapped to the URN. If none is mapped, apply the configured unknown-URN strategy (fail · delete · release · dead_letter).
  4. Preserve trace_id for logging and for any downstream messages.
  5. Treat meta and data as read-only; only the broker/worker mutates attempts.
  6. Tolerate unknown extra keys (forward-compatibility) — never hard-fail on them.
  7. 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 meta key (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_version and 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.