·8 min read ·Muhammet Şafak

Why the envelope is frozen at schema_version 1

Freezing the wire envelope is the whole product. Here's what 'frozen' means, what can still change, and how versioning keeps a v1 producer readable by every v1 consumer.

The envelope is the product. Six SDKs, one queue, no PHP serialize() — that only works if every language agrees, byte for byte, on the shape of a message, and keeps agreeing as the project ships releases for years. So we froze it. The wire envelope stays at schema_version: 1, and a v1 producer is readable by every v1 consumer, in any language, indefinitely.

This post is about what that freeze actually buys you, what it costs us, and where the seams are — because “frozen” does not mean “nothing can ever change.”

What “frozen” means

A BabelQueue message looks like this, in every language:

{
  "job": "urn:babel:orders:created",
  "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
  "data": { "order_id": 1042 },
  "meta": {
    "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
    "queue": "orders",
    "lang": "php",
    "schema_version": 1,
    "created_at": 1749132727000
  },
  "attempts": 0
}

Freezing means the set of required fields, their names, their types, and their meanings do not change while schema_version stays 1. A consumer that understood this shape on the day BabelQueue 1.0 shipped will understand it next year and the year after. No SDK is allowed to quietly rename job to urn, move attempts into meta, or start emitting created_at in seconds instead of milliseconds. Those are not bug fixes; each one would break a consumer in some other language that you do not control and may not even know is running.

The freeze is enforced, not aspirational. The wire contract is the source of truth, every SDK vendors a shared set of golden-envelope fixtures, and two SDKs producing the same logical message emit byte-comparable envelopes — only meta.id, trace_id, meta.lang, and meta.created_at legitimately differ, because they are per-message or per-language by definition.

What the contract pins down

“Frozen” is concrete because the contract is concrete. These are the fields, all required, and what they are:

Field Type What it is
job string The message URN — its language-agnostic identity, never a class name.
trace_id string (UUID) Cross-service correlation id, preserved and forwarded unchanged across every hop.
data object The business payload as pure JSON — no language-native types.
meta object Producer-set, immutable metadata (id, queue, lang, schema_version, created_at).
attempts integer Transport-level retry counter, starting at 0. The one field a broker may mutate.

Two of those entries carry most of the design weight. job holds a URN such as urn:babel:orders:created, never App\Jobs\OrderCreated, because a PHP class name means nothing to a Go worker — identity has to be a string every language can route on. attempts lives at the top level, deliberately outside the immutable meta block: it is transport state, not producer metadata, and keeping it top-level lets the BabelQueue Redis driver reuse Laravel’s native reservation script, which increments payload.attempts on every pop. Inside meta, created_at is Unix epoch milliseconds in UTC — not seconds, not a locale-formatted string — so six languages parse the same number the same way.

The contract also fixes how data is encoded, because the same bytes are decoded by six runtimes. UTF-8 JSON only; no NaN or Infinity; integers within signed 64-bit range (and under 2^53-1 if a Node consumer is in the chain, or carried as a string); no currency in floats — use integer minor units. Booleans are true/false, never 0/1. These read like style notes in one language and become interop bugs the moment a second language reads the payload, so they are part of the contract, not implementation detail.

What can still change

Freezing the envelope does not freeze the project. The rule is narrow:

  • Additive, optional, backward-compatible fields keep schema_version: 1. A new optional meta.* key that consumers already ignore is not a breaking change, so it does not bump the version. Consumers MUST tolerate unknown extra keys — never hard-fail on them — which is exactly what makes additive growth safe.
  • Removing, renaming, or retyping a field — or changing what an existing field means — bumps the version to 2, and that is treated as a major ecosystem event, not a routine change. It requires an ADR, an update to the contract and the JSON Schema, new golden fixtures, a compatibility window where consumers read both versions, and coordinated SDK releases.

The asymmetry is the whole point. Adding is cheap because old consumers ignore what they do not recognize. Removing or changing is expensive because some consumer, somewhere, in a language you did not write, depends on the old shape.

A worked example: the dead_letter block

The optional dead_letter block is additive-without-a-bump in practice. A message that lands on a dead-letter queue carries one extra top-level field describing why it failed:

"dead_letter": {
  "reason": "failed",
  "error": "Payment gateway timeout",
  "exception": "App\\Exceptions\\GatewayTimeout",
  "failed_at": 1749132730000,
  "original_queue": "orders",
  "attempts": 3,
  "lang": "php"
}

It appears only on DLQ messages, never on a normal queue, and the envelope stays at schema_version: 1. A consumer that has never heard of dead_letter ignores it, exactly as the forward-compatibility rule requires. A DLQ-aware tool reads it. Nobody had to rebuild and redeploy the fleet to introduce a cross-language dead-letter format — that is additive evolution working as designed.

Two version axes, on purpose

The most common way to break a polyglot fleet is to confuse two numbers that move independently:

Axis Versions Lives in Example
Envelope schema the wire format itself meta.schema_version (integer) 1
SDK package a library release per language the package registry (SemVer) babelqueue/laravel v1.4.2

The SDK packages follow Semantic Versioning and move on their own schedules. The Go SDK at v1.3.0 and the PHP SDK at v1.4.2 is normal — they are coupled through the envelope schema, not through their package numbers. An SDK can ship a MAJOR for a breaking change to its own API (a renamed method, a changed constructor) while the bytes on the queue stay at schema_version: 1, untouched. The wire envelope is explicitly exempt from package SemVer: it changes only via a schema_version bump plus an ADR, never as a side effect of any package’s major release.

There is a third axis worth naming: the data shape behind a single URN. If urn:babel:orders:created needs an incompatible payload change, you do not touch the envelope and you do not bump anyone’s package for it — you mint a new URN like urn:babel:orders:created.v2, run both in parallel, migrate, and retire the old one. Three axes, three reasons to change, kept apart so a change on one does not masquerade as a change on another.

How a v1 producer stays readable everywhere

When an envelope does eventually need a breaking change, one rule keeps the fleet intact: consumers upgrade before producers. You never emit a schema_version that no deployed consumer understands. Deploy the consumers that read both v1 and v2 first, switch producers to emit v2 second, and remove old-version support only after the migration window. A consumer MUST reject or quarantine an envelope whose schema_version it does not support — never best-effort parse it — so an out-of-order rollout fails loudly instead of corrupting data silently.

Until that day, a v1 producer in any language stays readable by every v1 consumer in every language. That is the guarantee the freeze exists to provide.

The forbidden fields, and why

A few fields are explicitly banned from the envelope. They are not just unused — emitting or relying on them is a contract violation:

Forbidden Use instead Why
timestamp (top-level) meta.created_at (Unix ms) Seconds precision and a duplicate of created_at.
meta.max_retries consumer-side config / per-URN policy Retry policy is the consumer’s, not the producer’s.
meta.source meta.lang Two names for the producer language; one wins.
meta.ts meta.created_at A second, ambiguous time field.

These shapes are real — they appeared in early drafts of the PRD and the marketing site before the contract was reconciled in ADR-0002, which picked one canonical envelope out of three competing ones. They are forbidden because the freeze only means something if there is exactly one way to express each fact. Two time fields, or two ways to name the producer language, hand every consumer a choice — and a polyglot fleet cannot survive each language guessing differently.

The honest trade-off

A freeze is a constraint, and it costs us flexibility. We cannot fix the envelope’s small imperfections by editing it — a wart in v1 is a wart we live with, or a schema_version: 2 migration that touches every SDK and every deployment. Adding a genuinely new field means finding an additive, optional shape that every one of six languages can carry, or paying the full coordinated-bump price. Most “small” envelope changes are not small once you count the consumers you do not control.

We took that constraint deliberately. The alternative — letting each SDK evolve the wire on its own schedule — is precisely the serialize() problem we set out to delete, where the bytes only make sense to the language that wrote them. The discipline is the feature. A frozen envelope is the reason you can produce in Laravel today and trust a Go worker to read it next year without a meeting.

Takeaway

Frozen means the required fields, types, and meanings of a schema_version: 1 envelope do not change — additive optional fields stay v1, while removing or retyping anything bumps the version behind an ADR and a consumers-first migration. The envelope schema and your SDK package versions move on separate axes, so you pin and upgrade libraries freely while the bytes on the queue stay stable. Read the field-by-field guarantees in the wire contract.

Cross-language dead-letter queues

Route failed and unroutable messages to a dead-letter queue any language can read, with a structured reason — opt-in, additive, and still schema_version 1.

Read article →

BabelQueue vs CloudEvents: two envelopes, different problems

CloudEvents standardizes how an event describes itself across protocols; BabelQueue freezes what a queue job looks like so six languages run it. An honest comparison — the real overlap, the real differences, and where CloudEvents is the better choice.

Read article →

What is a polyglot queue?

A polyglot queue is a message queue whose jobs are produced in one language and consumed natively in another. Why language-native serialization blocks that, the failure modes teams actually hit, and what a working solution has to provide.

Read article →