EDR 0040 Status: accepted Implementation: shipped
Declare a method's behaviour in one annotation, and never weaken it
A method's retry behaviour travels as a single MethodBehaviour extension rather than three, and once declared it may only strengthen. buf breaking ignores custom options, so a separate check compares every method against the base branch.
What exists: The one MethodBehaviour extension, the strengthen-only comparison (internal/schema/compat.go) and the idempotency_level agreement check all run in CI, via make lint and make breaking. The absent retry interceptor is not a missing half: this record's Scope section puts client-side retry behaviour outside it and leaves that obligation with EDR-0020.
TL;DR
EDR-0020 made every method declare whether repeating it is safe. This record fixes two things that decision did not say, both of which are cheap now and cannot be retrofitted once several methods exist.
One annotation, not three. The declaration is a single MethodBehaviour message extension —
safe, idempotency, idempotency_field — rather than three scalar extensions.
A declaration may only strengthen. Changing a method from safe to not-safe, from
IDEMPOTENCY_NATURAL to IDEMPOTENCY_KEYED or IDEMPOTENCY_UNSAFE, from IDEMPOTENCY_KEYED to
IDEMPOTENCY_UNSAFE, or moving the field its key travels in, is a breaking change. A method whose
behaviour genuinely changes gets a new method name, exactly as a field whose meaning changes gets a
new number.
Both are enforced by the build. safe additionally requires the standard
option idempotency_level = NO_SIDE_EFFECTS, because that is the option a generated Connect client
actually reads.
Context
Three separate facts forced this, and each was found by trying to defeat the guard EDR-0020 asked for rather than by reading it.
buf breaking does not look at custom method options. Its rules compare field numbers, names,
types and cardinality. A method can be reclassified from safe to IDEMPOTENCY_UNSAFE and buf breaking reports nothing — verified, exit 0. The annotation was enforced as present and not as
stable, which is half a guard: it stops a method being added without a declaration, and does
nothing about the declaration changing underneath clients that already compiled it.
A stale declaration is worse than none. A client built against the old schema has the retry policy compiled in. It keeps applying it until somebody rebuilds it, against a server that has moved on. That is the whole reason ZFN-19 asks for the annotation in the first place, and it is exactly the property a silent reclassification destroys.
Extension numbers in 50000–99999 are not globally unique. Protobuf reserves that range for use
within one organisation. Marque is a public, generic tool, so an adopter will import this schema
alongside their own options; a collision is a panic at registration, before main runs, with no
recoverable path. Three extensions were three chances to collide, and the numbers first chosen —
50001 upward — are the ones anybody numbering in-house options reaches for first.
There is also a plainer problem with three scalar extensions: they can be set half-way. An
idempotency of IDEMPOTENCY_KEYED with no idempotency_field is two annotations that disagree,
where one message is a single malformed value.
Decision
The annotation is one message. marque.v1.MethodBehaviour, carried by one extension on
google.protobuf.MethodOptions, at a number deliberately away from the bottom of the in-house range.
Before Marque is imported by anyone outside this repository, a globally-unique range is requested
from the protobuf global extension registry; until then the residual risk is one number rather than
three, and it is stated rather than assumed away.
A declaration may only strengthen. The rule follows from one question — does this change make the cached policy of a client built against the previous schema unsafe? Forbidden:
| Was | Becomes | Why it breaks |
|---|---|---|
safe |
not safe |
The old client retries freely, and the method is no longer read-only |
NATURAL |
KEYED |
The old client retries with no key, so the server cannot recognise the repeat |
NATURAL |
UNSAFE |
The old client retries something that must not be retried |
KEYED |
UNSAFE |
As above, carrying a key that no longer helps |
KEYED field a |
KEYED field b |
The old client keeps filling a, which the server now ignores |
Allowed, including the ones that read as widenings: UNSAFE to anything, because the old client
never retried; KEYED to NATURAL, because repeating became harmless; not-safe to safe.
The declaration and the standard idempotency_level agree, across all three of its values.
Connect's generator reads idempotency_level and nothing else, emitting WithIdempotency(…) onto
the method's Spec, where every interceptor can see it — including the retry interceptor this record
undertakes to build. A disagreement therefore does not stay in the schema; the generated client is
the one that acts.
idempotency_level |
Must pair with | Because |
|---|---|---|
NO_SIDE_EFFECTS |
safe |
It is the read-only claim, and it is what enables a GET |
IDEMPOTENT |
not safe, and not IDEMPOTENCY_UNSAFE |
It claims only that repeating is harmless — weaker than safe, and the opposite of unsafe |
| unset | not safe |
A safe method with nothing set is a claim no generated client can act on |
Read the table as a biconditional: safe requires NO_SIDE_EFFECTS, and NO_SIDE_EFFECTS
requires safe. Stating only the second is a live defect rather than an incomplete one, and it is
the mistake that was actually made here — a first implementation restructured this check around the
level and kept the safe direction in only one of its three branches, so safe with IDEMPOTENT
passed for a while.
The IDEMPOTENT-with-unsafe row is the dangerous one: without it a method could declare
IDEMPOTENCY_UNSAFE here and IDEMPOTENT there, and the generated client would believe the second.
Scope. This record covers the declaration and its stability. It does not cover client-side retry
behaviour beyond what idempotency_level already gives — an interceptor honouring keyed and
unsafe arrives with the first real client, and until then the annotation is enforced at build time
and consumed only for safe. Saying that plainly is better than implying the loop is closed.
Consequences
Easier.
- A reclassification cannot happen quietly. The check names the method, the transition, and what an already-built client will do.
- One extension is one collision surface, and the three fields cannot be set half-way.
safemeans something to a generated client today, rather than only to this repository's build.
Harder.
- A genuine change of behaviour now costs a new method name, and the old one has to live until
clients are gone. That is deliberate — it is the same cost a field number carries — but it will
feel disproportionate the first time a method wants to move from
NATURALtoKEYED. - The check needs the base branch's schema, so CI has to fetch enough history to build it. That is a real constraint on the checkout, and a shallow one silently removes the comparison unless the ref is asserted — which is why the target fails rather than skips when the ref is absent.
- The extension number is still not globally unique. The risk is reduced, not removed, and it stays that way until a range is registered upstream. An adopter who collides gets a panic, not a diagnostic.
- Reading a declaration now means reading a message rather than three options, which is one more
indirection in a
.protofile for anyone unfamiliar with message-typed extensions.
New obligations.
- Every new method declares
MethodBehaviour, andsafemethods also declareidempotency_level. - Request a globally-unique extension range before the first external adopter.
- An interceptor honouring
keyedandunsafeships with the first generated client that makes calls, and EDR-0020's claim that "generated clients honour it" is only fully true from that point. - Delete the bootstrap escape in
make breaking. Discharged 2026-08-17. Until this record's own change was on the main branch there was no previous schema to compare against, so the target said so and exited — correct exactly once. A base ref without a schema is now a hard failure, and "both are enforced by the build" is true without qualification.
A limit worth stating. The rule above is one-directional: it asks what an old client does
against a new server. The reverse skew — a client newer than the server it calls — is not covered.
Moving a method to safe is permitted, and a client that then opts into an HTTP GET will be refused
by a server that has not yet been upgraded. Nothing enables GET today, so this is not reachable; when
something does, it becomes a deployment-ordering question rather than a schema one, and this record
does not answer it.
References
- EDR-0020 — one schema generates every client, and the annotations this record makes precise.
- EDR-0011 — the execution nonce, which is the key an
IDEMPOTENCY_KEYEDmethod names. - ZFN-19 — annotate read-only and idempotent endpoints.
- ZFN-14 — define the API with a schema and generate the clients.
Changelog
- 2026-08-16: Accepted.
- 2026-08-17: The bootstrap escape in
make breakingis deleted, discharging the obligation above. A base ref carrying no schema now fails rather than reporting that there was nothing to compare, so both checks run on every pull request without qualification. The decision is unchanged.