AI agents

The Envelope's Version Field: Adding a Class Safely

Antonio Evans7 min read

A transparent glass envelope containing a JSON document.
Key points

Key points

  1. 1Keep the envelope's class field an open string, bump a minor version when you add a value, and make consumers fall back to a default class when they see one they do not know.
  2. 2Additive changes (new optional fields, new class values) are the only ones that keep old consumers working; removing or renaming anything is a major version and needs a migration, not a deprecation window.
  3. 3Emit the old class name alongside the new one for a fixed deprecation window, gate the new emission behind a feature flag, and count consumer fallbacks before you remove the old value.
  4. 4Treat the error envelope like a published schema: test compatibility in CI, log every fallback hit, and publish the class list as documentation consumers can diff.

Why the envelope is the riskiest place to add a class

An error envelope is the one payload every caller parses, and usually the one payload that gets the least schema discipline. Success responses get reviewed because the product depends on them; failures get a try/catch and a JSON.parse. That asymmetry is where breakage hides.

Production agents make this worse. Most tool call failures are silent: no exception, no crash, no alert, and the agent moves on as if the action happened in this failure taxonomy. A consumer that throws on an unrecognized class value turns a recoverable tool error into a dropped step, and the orchestrator never learns why. 401 from an expired token and 401 from a revoked token look identical at the execution layer but need different responses, and the same is true of your own error classes: they only help if the reader can handle a class it has never seen.

The base article on the error envelope assumes the class set is stable. It is not. Every quarter someone adds rate_limited, quota_exceeded, content_policy, or provider_timeout, and every addition is a chance to break an orchestrator you do not control.

The envelope contract, field by field

Here is a working schema. It is deliberately loose on class and tight on everything else.

{
  "version": "1.3",
  "class": "rate_limited",
  "code": "upstream_429",
  "message": "Upstream model provider returned 429; retry after 2s.",
  "retryable": true,
  "details": {
    "retry_after_ms": 2000,
    "provider": "example",
    "legacy_class": "throttled"
  }
}
FieldTypeContractCan it change in a minor version?
versionstring major.minorEnvelope schema version, not API versionYes, minor increments on additive change
classopen stringBroad recovery category; consumers must tolerate unknown valuesYes, new values allowed
codeopen stringSpecific machine code, for logs and routingYes, new values allowed
messagestringHuman-readable, no parsing guaranteedWording only, never structure
retryablebooleanOrchestrator decides retry vs replan from this, not from classNo: semantics fixed
detailsobjectFree-form, additive only, unknown keys ignoredYes, add keys only

The key decision is that class is an open string, not a closed enum. Closed enums are the classic compatibility trap: removing or adding a symbol has subtle effects that even schema registries have historically mis-handled as discussed for Avro enums. If you model class as an enum in a generated client, a new server value deserializes to a null or throws. Open string plus a documented known-values list plus a default is strictly safer.

The second decision is retryable. Split recovery responsibility explicitly: the orchestration layer owns transient retries, the model owns reasoning-based recovery when the payload is wrong per this error-handling guide. A boolean on the envelope makes that split mechanical. Do not make consumers infer retryability from class, because you will want to change one class's retryability without changing the class name.

Ratify the compatibility rules before the first bump

Write the rules down and enforce them in CI. The standard vocabulary is worth reusing: a backward-compatible change lets new readers read old data, a forward-compatible change lets old readers read new data, and full compatibility means both as Confluent defines the terms.

For an error envelope you want forward and backward compatibility at once, because producers and consumers roll out independently. Additive changes to an open JSON schema with additionalProperties allowed achieve that per this analysis of JSON Schema evolution. This table is the decision rule to paste into your contributing guide.

ChangeCompatible?Action
Add a new class valueYes, if consumers fall backMinor bump, announce, dual-write old class
Add an optional details keyYesMinor bump, note in changelog
Change message wordingYesNo bump, but no consumer may parse it
Make retryable optional or change its meaningNoMajor bump, migration window
Remove a class valueNo, for anyone still emitting or matching itDeprecation window, then major bump
Rename class to categoryNoNever; add category alongside and wait two majors
Change class to a closed enumNoNever
Change version from 1.3 to 2.0It is a major bumpAll consumers must accept or reject explicitly

Two details are easy to get wrong. First, always upgrade consumers before producers for backward-compatible changes, which is the standard operational rule for backward compatibility described in this guide. Second, treat unknown details keys as ignorable, but never treat an unknown top-level field as an error; a lenient reader is what makes additive changes safe.

Make the unknown class a first-class path

Set retryability at the boundary. Do not let consumers throw on an unknown class.

KNOWN_CLASSES = {"auth", "invalid_input", "rate_limited", "upstream", "not_found", "unknown"}

def normalize(envelope: dict, source: str) -> dict:
    v = envelope.get("version", "1.0")
    cls = envelope.get("class")
    if not isinstance(cls, str) or cls not in KNOWN_CLASSES:
        metrics.increment("envelope.unknown_class", tags={"source": source, "class": str(cls)})
        cls = "unknown"
    return {
        "version": v,
        "class": cls,
        "raw_class": envelope.get("class"),
        "retryable": bool(envelope.get("retryable", False)),
        "message": envelope.get("message", ""),
        "details": envelope.get("details") or {},
    }

The fallback rule the consumer needs is simple and worth stating verbatim in your docs: if you see a class you do not know, treat it as unknown, honor retryable, and do not retry a non-retryable unknown error. That last clause matters. 400 and 422 are not retryable while 429 and 503 are, and a client that cannot tell them apart either retries forever or gives up on transient failures per this status code decision guide. Your envelope should encode that distinction in the boolean, so unknown plus retryable: false is safe by construction.

For agents specifically, an unknown class should route to replan rather than retry. Persistent semantic errors, 409 conflicts and 422 rejections among them, will not clear on a backoff, and retrying them burns latency budget and can double-charge on non-idempotent tools argued in detail here. Counting fallbacks per source is how you find the one orchestrator that never got the message.

The rollout sequence, step by step

Order matters more than any single step. This sequence has four phases and a gate between each.

  1. Teach consumers first. Ship the open-string parsing, the default class, and the fallback metric to every consumer you control. Update the system prompt for LLM-based consumers too: production prompts are modular, versionable artifacts and output-format sections must state the unknown-class rule explicitly per these design patterns. If agents read the envelope, the instruction "if the class is not in this list, treat it as unknown and check retryable" belongs in the prompt, not in a wiki.
  2. Emit both classes, behind a flag. The producer writes the new class and the old name in details.legacy_class, and keeps the old top-level value for any consumer pinned to it. Gate the new emission with a feature flag so you can roll back without a deploy.
  3. Watch the fallback counters. Hold the flag on until fallback hits for the new class are zero, or stable and explained, for longer than your longest deploy and cache TTL. If an external consumer cannot ship in that window, stop and extend. Publish the class list so consumers can diff it.
  4. Flip the default and retire. Turn the flag off for the old emission, keep the old class in the known list for one more window, then remove it in a major bump. Announce before each step.

Fault injection belongs in phase 1. You cannot trust an error path you have never exercised. Fault-injection frameworks exist precisely to test how agents respond to malformed inputs and tool failures, and the evidence they produce is subtler than it looks: in one study, among 44 runs where counterevidence eventually became visible, only 17 received it before the faulty fact was first used SSCBench. Translate that to your tests: inject the new class in a run where the consumer must act on it before any fallback path has been exercised, and check the fallback actually fires.

A dependable injection proxy makes this a test suite rather than a manual drill the Agent Error Simulator. If tool errors are handled at the wrong layer, agents degrade silently as this retry guide warns.

Monitoring: what to count and what to alert on

You need three signals, and only one of them should page anyone.

SignalWhat it meansAction
envelope.fallback_total by class and consumerA consumer saw a class it does not knowDashboard; investigate if it persists after rollout
envelope.version_skewConsumer and producer disagree on the envelope versionDashboard; check for a stale deploy
envelope.parse_error_totalA consumer failed to parse the envelope at allAlert; this is a contract break

Watch for the silent version of this problem as well. A consumer that falls back to unknown and then treats unknown as "retry" will look healthy in your dashboards while quietly retrying work that can never succeed. Datadog data cited in that failure analysis puts one in twenty AI model requests failing in production while systems keep returning outputs that appear correct source. Fallback hits are the one early warning you get.

If you would rather not hand-roll compatibility tests and fallback metrics per provider, one API for text, image, video, audio and embedding models keeps the envelope shape consistent across providers: Quanta Inference is OpenAI-compatible and prepaid, which removes one class of variance (provider-specific error shapes) before you even version your own.

FAQ

FAQ

Is adding a new class value a breaking change?

Not for consumers that treat class as an open string and fall back to a default. It is breaking for any consumer that models class as a closed enum or switches on it without a default branch per these evolution rules. The fix is on the consumer side, which is why you teach consumers first and emit second.

Should I bump the major version when I add a class?

No. Bump the minor version, keep the major version stable, and reserve major bumps for changes that remove a value or change the meaning of a field like retryable. Major bumps are for changes where every consumer must act as this API versioning discussion argues.

How long should the deprecation window be?

Long enough to cover your slowest consumer's deploy cycle plus any cache TTL, and not shorter than the window you have held before. The gate is data, not the calendar: hold the dual-write until fallback hits for the new class are zero or explained. If you cannot get that number, keep the old class in the known list and extend.

What should the fallback class actually do?

Treat it as unknown, honor retryable, and if you are an agent, replan rather than retry when retryable is false. Persistent semantic errors do not clear on a backoff, and retrying a non-idempotent tool can duplicate the action per this replan argument. Log the raw class value so you can add it later.

How do I test that old consumers survive the new class?

Inject the new class through a proxy into a run where the consumer must act on the error before any fallback path has been tested, and assert the fallback fires using a fault-injection proxy. Remember that visibility timing changes the result: evidence arriving after first use does not test what you think it tests SSCBench. If you want the rollout reviewed by people who do this for a living, a consulting call is the fastest path.

Sources

Sources

  1. Tool Call Failures in Production: Debug and Prevent Guide
  2. Backwards compatibility of enum fields - apache kafka
  3. LLM Tool Calling Error Handling: Retries and Fallbacks
  4. Schema Evolution and Compatibility for Schema Registry ...
  5. Evolving JSON Schemas - Part II
  6. Backward Compatibility in Schema Evolution: Guide
  7. Choosing the Right HTTP Status Codes for Your API
  8. Replan, Don't Retry: Why Most Agent Errors Aren't Transient
  9. System Prompts for Agents: 12 Patterns - Blck Alpaca
  10. SSCBench: Evaluating the Evidential Validity of Fault- ...
  11. Agent Error Simulator: Fault Injection for Testing Recovery ...
  12. Building Retries in Agents
  13. API Versioning Has No “Right Way” | by Phil Sturgeon
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles