The Envelope

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.

Field reference

FieldTypeRequiredMutableDescription
jobstringnoThe 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_idstring (UUID)noCross-service correlation id. Generated by the original producer; preserved and forwarded unchanged by every SDK across every hop. Enables end-to-end distributed tracing.
dataobjectnoThe 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.
metaobjectno¹Producer-set descriptive metadata. Immutable after production. See the meta block.
attemptsintegeryesTransport-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

FieldTypeRequiredDescription
idstring (UUID)Unique id for this specific message. Distinct from trace_id.
queuestringThe logical queue name the message was produced onto (not the broker key).
langstring (enum)Producer language tag — one of php, go, python, java, dotnet, node. For observability and debugging.
schema_versionintegerEnvelope schema version. Currently 1. Consumers MUST reject (or quarantine) versions they do not understand.
created_atintegerProduction 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.

id vs trace_id — do not conflate

meta.idtrace_id
Scopeone messageone causal chain (may span many messages/services)
Generated bythe producing SDK, per messagethe first producer in the chain; reused thereafter
Lifetimedies with the messagepropagates across every hop
Usededupe, ack/nack, logs for one messageend-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.

Forbidden fields

These appeared in early drafts and are not part of the contract. Do not emit or rely on them:

Forbidden fieldUse instead
timestamp (top-level)meta.created_at (Unix ms)
meta.max_retriesconsumer-side config / per-URN policy
meta.attemptstop-level attempts
meta.sourcemeta.lang
meta.tsmeta.created_at
urn as the canonical producer fieldjob (with urn accepted only as an inbound alias)

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.
  • Additive, optional, backward-compatible fields → keep schema_version = 1.
  • Removing/renaming/retyping a field, or changing semantics → bump schema_version and provide a migration/compat path for older consumers.

Continue to URN naming.