--- title: "Why the envelope is frozen at schema_version 1" description: "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." source: https://babelqueue.com/blog/why-the-envelope-is-frozen/ pubDate: 2026-08-06T00:00:00.000Z --- 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](/glossary/#wire-envelope) stays at [`schema_version: 1`](/glossary/#schema-version), 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: ```json { "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](/docs/spec/1.x/envelope/) 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: ```json "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](https://semver.org) 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](/docs/spec/1.x/envelope/), 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](/docs/spec/1.x/envelope/).