---
id: 51
title: "Design the request envelope before the first endpoint"
status: current
kind: note
date: 2026-07-30
authors:
  - "Theo Zourzouvillys"
tags: [api, architecture, design, http, interop]
summary: "Auth, idempotency keys, trace IDs, vector clocks: context about a request, not part of it. Define an envelope alongside the payload in the schema on day one — transport-native, per-hop vs propagated, owned by generated SDKs and middleware. The retrofit is what costs you."
supersedes: null
superseded_by: null
aliases: []
references:
  - id: tracecontext
    title: "W3C Trace Context (W3C Recommendation)"
    url: https://www.w3.org/TR/trace-context/
    abstract: "Standardises `traceparent` and `tracestate` as the interoperable way to propagate a trace identifier and vendor-specific trace state across service boundaries, so a request can be correlated end to end even when hops are instrumented by different vendors."
  - id: baggage
    title: "W3C Baggage"
    url: https://www.w3.org/TR/baggage/
    abstract: "Defines a `baggage` header carrying arbitrary key-value context that propagates along the causal chain of a distributed request, so a value set at the edge is available to every downstream hop without any intermediary having to understand it."
  - id: idempotency
    title: "The Idempotency-Key HTTP Header Field (IETF draft)"
    url: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
    abstract: "Specifies an `Idempotency-Key` request header letting a client mark a mutating request with a unique key so servers can deduplicate retried requests, together with the expected server behaviour for concurrent, replayed, and mismatched-payload cases."
  - id: dpop
    title: "RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)"
    url: https://www.rfc-editor.org/rfc/rfc9449.html
    abstract: "Defines a mechanism to bind an access token to a client-held key: the client sends a signed DPoP proof header on each request, so a stolen token cannot be replayed by a party that doesn't hold the corresponding private key."
---

## TL;DR

Every API accumulates a set of concerns that are **about** a request rather than **in** it: who's
asking and with what proof, don't-apply-this-twice, this call belongs to that trace, here's how fresh my
view of the world is, here's the deadline. These are cross-cutting — every endpoint has them, no
endpoint is *about* them.

Give them a **home in the wire format that is not the payload**, and do it before you ship the first
endpoint:

- **Three layers, named.** **Payload** — the domain object. **Envelope** — per-hop metadata about this
  call (auth proof, idempotency key, deadline, quota key). **Baggage** — context that propagates
  transitively down the whole causal chain (trace id, vector clock, originating principal, tenant),
  which intermediate hops carry without understanding.
- **Put it in the schema, not in the message types.** The envelope is part of the API contract
  ([ZFN-14](/zfn/14-schema-first-apis-generate-clients/)), defined once and generated into every client
  and server stub — not three fields copy-pasted onto the top of every request message.
- **Use the transport's metadata layer.** HTTP headers, gRPC metadata, message attributes. Never the
  body: you can't route on it at ingress without deserialising a domain object, and it can't cross a hop
  that doesn't have your schema.
- **Mark each field per-hop or propagated.** Both mistakes bite: an idempotency key that propagates
  makes two distinct downstream writes collide; a trace id that doesn't propagate ends at the first
  queue.
- **The SDK and the middleware own it, not application code.** `orders.create(…)` should never mention
  a vector clock. The client holds what it last saw and presents it; the server reads the envelope in
  middleware and hands handlers a domain object.
- **Use the existing standards.** [`traceparent`/`tracestate`](ref:tracecontext),
  [`baggage`](ref:baggage), [`Idempotency-Key`](ref:idempotency), [DPoP](ref:dpop). Don't mint
  `X-Acme-Trace-Id` ([ZFN-30](/zfn/30-use-standards-dont-reinvent/)).

## Context

Start a new API and the first cross-cutting concern arrives immediately: authentication. That one gets
handled properly, because it arrives before there's anything to bolt it onto — it goes in a header, the
client library attaches it, middleware validates it, handlers never see it. Nobody has ever proposed
putting the access token in the request body.

Then the rest arrive one at a time, each in its own quarter, each solved on its own terms:

- **Idempotency keys** show up when the first retry double-charges someone. They get a header — but only
  on the three endpoints that hurt, and the "generate a fresh key per attempt" bug lives in two of the
  four SDKs ([ZFN-19](/zfn/19-annotate-readonly-idempotent-endpoints/),
  [ZFN-13](/zfn/13-load-shedding-and-flow-control/)).
- **Trace ids** show up when an incident takes four hours to attribute. Whatever the tracing agent
  auto-instruments becomes the de facto contract, which means the trace survives HTTP hops and dies at
  every queue and every hand-rolled client.
- **Vector clocks** show up when a customer reports the "I saved it and it's gone" bug
  ([ZFN-25](/zfn/25-read-your-writes-version-token/)). And this is where it goes wrong, because by now
  the envelope has no owner and no obvious place for a new field — so the version token goes into the
  request body, since the payload is the only part of the contract the team building the feature
  controls.

That last decision is the expensive one, and it's worth being precise about why. Once the vector clock
is a field on `CreateOrderRequest`, it must also be on `UpdateOrderRequest`, and `ListOrders`, and every
message type added afterwards, forever. It is now versioned with your domain model, so changing its
shape is a schema migration across every message. It can't be read by anything that doesn't have your
schema — no ingress routing on it, no gateway holding the session's clock
([ZFN-16](/zfn/16-separate-data-plane-control-plane/)). It doesn't survive a hop into a queue or a WAL
record ([ZFN-48](/zfn/48-emit-async-work-into-the-wal/), [ZFN-12](/zfn/12-queues-topics-journals/))
unless someone remembers to copy it by hand into a different shape. And the business logic now touches
it, because it arrived in the same object as the domain data.

The failure mode isn't that it doesn't work. It's that it works on *most* paths. Some endpoint added in
a hurry omits the field; some async hop drops it; some SDK never learned to echo it back.
Read-your-writes then holds *usually*, which is precisely the intermittent, unreproducible bug the
vector clock existed to eliminate. Partial propagation is worse than no propagation, because no
propagation is at least honest about what it doesn't give you.

> [!aside]
>
> The tell is easy to spot in a schema review: the same three or four fields copy-pasted at the top of
> every request message in a `.proto` or an OpenAPI spec — `request_id`, `trace_id`, `client_version` —
> and exactly one message, added last month, that's missing one of them. Nobody decided that. It's
> sediment.

## Recommendation

**Define the envelope as a first-class part of the API contract, before the first endpoint exists — and
route every cross-cutting concern into it rather than into the payload.**

### Separate the three layers, explicitly

- **Payload** — the domain object. What the operation is about. Owned by the team that owns the
  endpoint.
- **Envelope** — metadata about *this call, this hop*: credential and proof of possession, idempotency
  key, deadline/timeout budget, quota key, content negotiation, client identity and version. Consumed by
  infrastructure and middleware; handlers rarely read it directly.
- **Baggage** — context that must survive the *whole causal chain*, including async hops, and that
  intermediate services carry without needing to understand: [trace context](ref:tracecontext), the
  vector clock, the originating principal, the tenant, a sampling or debug flag.

The envelope/baggage split is the one people skip, and it's the one that decides whether a value
survives the first queue. Write it down per field, in the contract: **per-hop** or **propagated**, and
whether it's client-settable or server-only. Getting it backwards produces real bugs in both directions
— a propagated idempotency key makes two genuinely different downstream writes collide on one key, and
a per-hop trace id makes every async boundary the end of a trace.

### Put it in the schema, and let the transport carry it

If the API is defined by a schema and the clients are generated
([ZFN-14](/zfn/14-schema-first-apis-generate-clients/)), the envelope is defined *there*, as a type
distinct from any request message — so it lands in every generated client and every server stub without
anyone remembering it. On the wire it rides the metadata layer the transport already has: HTTP headers,
gRPC metadata, message attributes on a queue, a header block on a WAL record. Every mainstream transport
has one; that is what it is for.

This matters beyond tidiness, because the envelope is the part **intermediaries** need. Ingress has to
rate-limit and reject on it without parsing a domain object
([ZFN-18](/zfn/18-enforce-quotas-at-ingress/)); a gateway routing reads to a caught-up replica needs the
version token before it knows which service it's calling
([ZFN-25](/zfn/25-read-your-writes-version-token/)); a proxy has to forward baggage for services whose
schemas it has never seen. Anything in the body is invisible to all of them.

### Make it the SDK's job and the middleware's job

Application code should not mention any of this. The generated client:

- attaches the credential and mints a fresh [DPoP proof](ref:dpop) per request
  ([ZFN-6](/zfn/6-sender-constrained-tokens-dpop/));
- generates **one** idempotency key per logical operation and reuses it across every retry of that
  operation — the retry policy and the key are one feature, not two
  ([ZFN-13](/zfn/13-load-shedding-and-flow-control/));
- starts or continues the trace span and emits `traceparent`;
- stores the version token returned by the last write and presents it on subsequent reads automatically,
  so read-your-writes becomes a property of using the SDK rather than something each caller remembers.

On the server, the envelope is parsed, validated, and turned into a request context by middleware.
Handlers receive a domain object and an ambient context. If a handler is reading a header, the layering
has already leaked.

### Make it bidirectional

Responses need an envelope too: the version token or vector clock the write produced, the trace id (so a
support ticket can carry it), `Retry-After` and quota state, deprecation and sunset signals, an
idempotent-replay indicator. Each of these otherwise becomes a field on every response type, with the
same sediment problem. A response envelope also gives you somewhere to put the *next* cross-cutting
concern without touching a single response schema.

### Bound it, and don't trust it

A general propagation mechanism is an attractive nuisance. First a vector clock, then a feature flag,
then someone's serialised session. Constrain it:

- **Enumerate the fields** in the schema. "Arbitrary key-values" is a transport capability, not a
  licence; unknown keys get dropped or rejected at ingress.
- **Cap the total size.** Proxies and servers enforce header limits, and the failure mode is a `431` or
  a silently truncated header, not a clean error you'll notice in testing.
- **Propagated context is an input, never an authority.** A tenant id in baggage is a hint for logging
  and routing; the authorization decision comes from the authenticated credential, always
  ([ZFN-38](/zfn/38-agents-are-principals/), [ZFN-40](/zfn/40-no-anonymous-system-actor/)). Strip
  client-supplied values for any key the server treats as trusted, at the trust boundary, rather than
  hoping nobody sets them.
- **Define absent behaviour per field.** Someone will always call you with `curl`. Every field needs a
  defined meaning when missing — usually "you don't get that guarantee," never "undefined."

### Do it on day one

The cost asymmetry is the whole argument. Defining an empty envelope with two fields and a propagation
rule costs an afternoon when there are no endpoints yet. Adding one once there are two hundred
endpoints, four SDKs, and a dozen internal services means touching every handler, versioning every
message type that already carries these as body fields, and living through a long migration in which
some paths propagate and some don't. That intermediate state is the one that hurts, because the
guarantees are partial and silent — you cannot tell from a green test suite which paths lost the clock.

You don't need to know on day one that you'll eventually want a vector clock. You only need the place to
put it.

## Consequences

**Easier:**

- A new cross-cutting concern becomes one envelope field plus one change in the client and one in the
  middleware — instead of a change to every endpoint, every message type, and every SDK.
- Infrastructure can act on request context without understanding the payload: quota enforcement at
  ingress, freshness-aware read routing, trace stitching, deadline propagation.
- Async hops stop dropping context. You copy *an envelope* across the boundary, which is one thing to
  remember, rather than N fields, which is N things to forget.
- Domain schemas stay about the domain, and handlers stay testable without constructing a request
  context.
- Because the fields are standard ones, off-the-shelf proxies, tracing backends, and other people's
  clients already speak them ([ZFN-30](/zfn/30-use-standards-dont-reinvent/),
  [ZFN-45](/zfn/45-read-the-standards/)).

**Harder:**

- You now have a second contract, and it's a global one: adding or changing an envelope field touches
  every caller and every service at once. It needs an owner and a change process
  ([ZFN-47](/zfn/47-govern-the-contract-between-teams/)) — precisely the kind of boundary contract worth
  governing centrally, and precisely the kind of thing that rots without one.
- Baggage is unbounded by default and crosses trust boundaries. Size caps, allowlists, and a hard rule
  that propagated values are never authorization inputs are all now your problem — and a real attack
  surface if you skip them.
- It puts machinery into the client. A thin generated wrapper acquires a small runtime (key state, trace
  context, retry-scoped idempotency keys), and that runtime has to behave identically in every language
  you ship.
- Header-based metadata has real limits: size caps, proxies that strip unknown headers, and transports
  whose metadata layer is weaker than HTTP's. Some hops need an explicit mapping rather than a straight
  copy.

**New obligations:**

- Every new transport or hop you introduce has to carry the envelope, or the chain breaks silently
  there. That's a checklist item for any new queue, cache, proxy, or protocol boundary.
- Every envelope field needs documented behaviour when absent, so hand-rolled clients degrade
  predictably instead of mysteriously.
- The SDK becomes where these guarantees actually live, which makes SDK bugs protocol bugs. Test the
  envelope behaviour — a retry reuses the key, the clock round-trips, the trace survives a queue hop —
  in the contract test suite, not as an SDK implementation detail.

## References

- [ZFN-14](/zfn/14-schema-first-apis-generate-clients/) — the envelope belongs in the schema and gets
  generated into clients, exactly like the payload does.
- [ZFN-19](/zfn/19-annotate-readonly-idempotent-endpoints/) — idempotency keys are an envelope concern;
  annotating endpoints is what lets infrastructure act on the envelope safely.
- [ZFN-25](/zfn/25-read-your-writes-version-token/) — the version token / vector clock that this note
  says must travel as baggage rather than as a payload field.
- [ZFN-13](/zfn/13-load-shedding-and-flow-control/) — retries and idempotency keys are one feature, and
  the client owns both.
- [ZFN-47](/zfn/47-govern-the-contract-between-teams/) — the envelope is the boundary contract worth
  governing centrally.
- [ZFN-30](/zfn/30-use-standards-dont-reinvent/), [ZFN-45](/zfn/45-read-the-standards/) — every field
  here already has a standard; use it.
- [W3C Trace Context](ref:tracecontext) and [W3C Baggage](ref:baggage) — the propagation layer, already
  specified.
- [`Idempotency-Key` HTTP header](ref:idempotency) and [RFC 9449 DPoP](ref:dpop) — per-hop envelope
  fields with existing specifications.

## Changelog

- **2026-07-30**: First published as a Field Note.
