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 optionalmeta.*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.