<?xml version="1.0" encoding="UTF-8"?>
<spec xmlns="https://vibevm.org/spec/1">
  <title>How large consumer-scale systems let old clients survive server-side schema changes</title>
  <p p="1">**Research date / access date for every source below: 2026-08-09.**
Every claim carries a verbatim quote, a URL, and that access date. Where an authoritative
answer could not be found, the finding is marked **NOT FOUND** with the searches performed.
Primary = the organisation's own engineering blog, docs, spec, or source code.
Secondary = third-party blog, summary, or commentary (labelled and treated as weak).</p>
  <section title="§0 The one-line answer, stated up front">
    <p p="2">The mobile playbook is not one technique. It is **three techniques stacked**, and they are
load-bearing in this order:</p>
    <list ordered="true" p="3">
      <item>**The server knows its clients.** It can measure which app versions are live, which
   fields they request, and it can decide when a shape is safe to stop serving.</item>
      <item>**The client is a compiled artifact the vendor built**, so the vendor can mandate
   defensive decoding in its own codegen and can, at the limit, refuse to serve an old build.</item>
      <item>**Only then** does the "additive-only, versionless schema" story work.</item>
    </list>
    <p p="4">Layer 1 and layer 2 are exactly what a JSON file published into a git repository and read
by arbitrary third-party tools does not have. The detailed verdict is §4.2: of seventeen
practices found, **six transfer fully, three transfer only in weakened form, and eight do not
transfer at all**.</p>
    <p p="5">**But the conclusion is not "freeze the format."** None of the organisations studied froze
anything — Badoo ships nine releases a week, Kubernetes changes its API quarterly, Meta ships
continuously while supporting three years of old app builds. What they bought was not stasis but
**the ability to change without needing permission from consumers they cannot reach**, and most
of what they paid for it *is* purchasable at rest. **§4.4 is the constructive section**: how to
keep a git-published JSON format genuinely evolvable, assembled only from practices that survive
the translation. Its load-bearing ideas are (a) stamp every artifact, because an unversioned
format cannot evolve, it can only accrete — Discord's frozen default is the published proof;
(b) Kubernetes' split between *stop writing* an old shape (cheap, do it whenever) and *stop
reading* one (never); (c) guarantee lossless round-tripping via stable field identity, as Thrift
and Iceberg do; (d) grow closed vocabularies by widening into a new field rather than adding in
place; and (e) gate the diff in CI, which is what buys the confidence to move fast — the two
organisations that let themselves stay versionless internally, LinkedIn and Uber, are exactly
the two with a hard CI gate.</p>
    <p p="6">**The one thing that genuinely does not transfer, and has no workaround:** you will never know
who is still reading the old shape. Netflix removes a deprecated field "once the stats show that
a deprecated field is no longer used"; that evidence does not exist for a published file. So
transition windows are chosen by judgement, and (b) above is what makes choosing wrong
survivable instead of fatal.</p>
  </section>
  <section title="§1 Per-subject findings">
    <section title="1.1 Meta / Facebook">
      <section title="1.1.1 Thrift — the original whitepaper (PRIMARY)">
        <p p="7">Source: Mark Slee, Aditya Agarwal, Marc Kwiatkowski, *"Thrift: Scalable Cross-Language
Services Implementation"*, Facebook, 2007.
URL: https://thrift.apache.org/static/files/thrift-20070401.pdf — accessed 2026-08-09.
(Text extracted locally from the PDF; section numbers as printed.)</p>
        <p p="8">The design goal is stated in §1 and it explicitly includes **data at rest**:</p>
        <quote p="9">"Versioning. For robust services, the involved datatypes must provide a mechanism for
versioning themselves. Specifically, it should be possible to add or remove fields in an
object or alter the argument list of a function without any interruption in service (or,
worse yet, nasty segmentation faults)."</quote>
        <p p="10">§5 opening — note the second sentence, which is the single most relevant sentence in the
whole paper for the data-at-rest question:</p>
        <quote p="11">"Thrift is robust in the face of versioning and data definition changes. This is critical
to enable staged rollouts of changes to deployed services. The system must be able to
support reading of old data from log files, as well as requests from out-of-date clients
to new servers, and vice versa."</quote>
        <p p="12">**Q1 — version or versionless?** Versionless at the IDL level. There is no protocol version
number in the payload; identity is carried per-field:</p>
        <quote p="13">"Versioning in Thrift is implemented via field identifiers. The field header for every
member of a struct in Thrift is encoded with a unique field identifier. The combination of
this field identifier and its type specifier is used to uniquely identify the field."</quote>
        <quote p="14">"The Thrift definition language supports automatic assignment of field identifiers, but it
is good programming practice to always explicitly specify field identifiers."</quote>
        <p p="15">**Q2 — who is tolerant?** Explicitly the **reader**, and the mechanism is self-delimiting
types:</p>
        <quote p="16">"When data is being deserialized, the generated code can use these identifiers to properly
identify the field and determine whether it aligns with a field in its definition file. If
a field identifier is not recognized, the generated code can use the type specifier to skip
the unknown field without any error. Again, this is possible due to the fact that all
datatypes are self delimiting."</quote>
        <quote p="17">"When an unexpected field is encountered, it can be safely ignored and discarded. When an
expected field is not found, there must be some way to signal to the developer that it was
not present. This is implemented via an inner isset structure inside the defined objects."</quote>
        <quote p="18">"When a reader receives a struct, it should check for a field being set before operating
directly on it."</quote>
        <p p="19">The four-case analysis (§5.3) is quoted in full because it is the clearest published
statement of who bears which risk:</p>
        <quote p="20">"1. Added field, old client, new server. In this case, the old client does not send the new
field. The new server recognizes that the field is not set, and implements default behavior
for out-of-date requests.
2. Removed field, old client, new server. In this case, the old client sends the removed
field. The new server simply ignores it.
3. Added field, new client, old server. The new client sends a field that the old server
does not recognize. The old server simply ignores it and processes as normal.
4. Removed field, new client, old server. This is the most dangerous case, as the old server
is unlikely to have suitable default behavior implemented for the missing field. It is
recommended that in this situation the new server be rolled out prior to the new clients."</quote>
        <p p="21">Note that all four cases concern **fields**. The paper contains **no** case analysis for
enum value growth. That omission is not accidental — see §3.</p>
        <p p="22">§5.4 contains the one piece of genuinely file-oriented advice in the paper:</p>
        <quote p="23">"For example, if we wished to add some new checksumming or error detection to the
TFileTransport, we could simply add a version header into the data it writes to the file in
such a way that it would still accept old log files without the given header."</quote>
        <p p="24">**Q3 — enums.** NOT FOUND in the whitepaper. The paper never discusses enum evolution. What
*is* published is that Thrift's generated code historically **crashes** on unknown enum
values — see §1.1.2 and §3.</p>
        <p p="25">**Q5 — deprecation lifecycle.** NOT FOUND in the whitepaper.
**Q6 — CI checking.** NOT FOUND in the whitepaper.</p>
      </section>
      <section title="1.1.2 Thrift enums in practice — the published bug reports (PRIMARY, issue trackers)">
        <p p="26">Microsoft `thrifty` (a Thrift implementation for Android/Kotlin), issue #84, opened by
maintainer Ben Bader. URL: https://github.com/microsoft/thrifty/issues/84 — accessed 2026-08-09.</p>
        <quote p="27">"clients receiving a `Baz` with the new enum value will crash. This is because the generated
`Foo.findByValue` method returns null, and the adapter will attempt to set the field with
that null value."</quote>
        <quote p="28">"It seems wrong to attempt to overwrite a default value with `null`. Conversely, it also
seems wrong to silently ignore incomprehensible data."</quote>
        <p p="29">Apache Thrift THRIFT-5392, "Thrift Enums should generate forward compatible enum like code".
URL: https://www.mail-archive.com/dev@thrift.apache.org/msg50731.html — accessed 2026-08-09.
Allen George (Apache Thrift committer) states the design constraint plainly:</p>
        <quote p="30">"to support forward-compatibility, you have to have the ability to create enum variants
without a named value and encode them onto the wire."</quote>
        <p p="31">This is the crux of §3: forward compatibility for a closed vocabulary requires the
**decoded representation to be able to hold a value the code does not know**. A plain
language-level enum cannot do that.</p>
      </section>
      <section title="1.1.3 GraphQL — Meta&apos;s own stated rationale (PRIMARY)">
        <p p="32">Source: Lee Byron, *"GraphQL: A data query language"*, Engineering at Meta, 2015-09-14.
URL: https://engineering.fb.com/2015/09/14/core-infra/graphql-a-data-query-language/ —
accessed 2026-08-09.</p>
        <p p="33">**Q1 — version or versionless?** Versionless, and Meta says why. The full "Version free"
paragraph, verified by two independent fetches on 2026-08-09:</p>
        <quote p="34">"**Version free:** The shape of the returned data is determined entirely by the client's
query, so servers become simpler and easy to generalize. When you're adding new product
features, additional fields can be added to the server, leaving existing clients unaffected.
When you're sunsetting older features, the corresponding server fields can be deprecated but
continue to function. This gradual, backward-compatible process removes the need for an
incrementing version number."</quote>
        <p p="35">Read the second sentence carefully: *"additional fields can be added to the server, leaving
existing clients unaffected."* The mechanism is **not** that the client tolerates the new
field — it is that the client **never receives it**, because it did not name it. That
distinction is the hinge of §4.</p>
        <p p="36">**The two numbers that matter** — these are the strongest published evidence anywhere that
versionless evolution works at consumer scale, and they are Meta's own:</p>
        <quote p="37">"We still support three years of released Facebook applications on the same version of our
GraphQL API."</quote>
        <quote p="38">"GraphQL powers almost all data-fetching in our mobile applications, serving millions of
requests per second from nearly 1,000 shipped application versions."</quote>
        <p p="39">The mobile motivation:</p>
        <quote p="40">"When we built Facebook's mobile applications, we needed a data-fetching API powerful enough
to describe all of Facebook, yet simple enough to be easy to learn and use by our product
developers."</quote>
        <p p="41">**Q2 — who is tolerant?** Structurally, the *query* is the tolerance mechanism: an old client
sends an old query and receives exactly the old shape, because it named the fields it wanted.
Additive server changes are invisible to it. This is a genuinely different mechanism from
"the reader skips what it does not know" — the reader never receives what it did not ask for.
This is the single most important architectural observation in this whole report, and §4
turns on it.</p>
        <p p="42">**Q4 — forced upgrade.** NOT FOUND. Meta publishes no statement in this post about
force-upgrading clients. The "three years / 1,000 versions" figure is evidence *against*
routine force-upgrade for the main app. Searched: engineering.fb.com for GraphQL +
versionless + mobile app old versions; searched for Lee Byron statements on old app versions
(the reactiflux Q&amp;A transcript at
https://raw.githubusercontent.com/reactiflux/q-and-a/master/lee-byron_facebook-graphql.md,
accessed 2026-08-09, contains **no** discussion of versioning, deprecation, or legacy app
support — verified by fetch).</p>
      </section>
      <section title="1.1.4 Facebook Graph API — the dated-version scheme (PRIMARY)">
        <p p="43">URL: https://developers.facebook.com/docs/graph-api/guides/versioning/ — accessed 2026-08-09.</p>
        <p p="44">Note the sharp contradiction with §1.1.3: Meta's **internal** mobile API is versionless, and
Meta's **external third-party** API is explicitly versioned with a hard clock. Same company,
opposite answer, and the difference is precisely whether the consumer is controlled.</p>
        <p p="45">**Q1 — version or versionless?** Explicitly versioned.
**Q5 — deprecation lifecycle. Published duration: two years.**</p>
        <quote p="46">"Each version will remain for at least 2 years from release giving you a solid timeline for
how long your app will remain working."</quote>
        <p p="47">The failure mode is a **silent downgrade**, not an error:</p>
        <quote p="48">"For APIs, once a version is no longer usable, any calls made to it will be defaulted to the
next oldest, usable version."</quote>
        <quote p="49">"An unversioned call uses the version set in the app dashboard **Upgrade API Version** card
under **Settings &gt; Advanced**."</quote>
        <p p="50">There is an explicit carve-out that voids the two-year guarantee:</p>
        <quote p="51">"Facebook does reserve the right to make changes in any API in a short period of time for
issues related to security or privacy."</quote>
        <p p="52">**Q2 / Q3 for the Graph API.** NOT FOUND. Fetched
https://developers.facebook.com/docs/graph-api/changelog/breaking-changes/ (accessed
2026-08-09); it is an index of dated changelog entries with **no** definition of a breaking
change and **no** normative statement about client tolerance or enum growth.</p>
      </section>
    </section>
    <section title="1.2 GraphQL as a movement">
      <section title="1.2.1 The versionless claim, in the official docs (PRIMARY)">
        <p p="53">graphql.org returns HTTP 403 to automated fetches. The identical text was obtained from the
site's own source repository and from a public mirror of the same page; both are quoted with
their URLs.</p>
        <p p="54">Source: graphql.github.io source repo, `faq/best-practices`.
URL: https://raw.githubusercontent.com/graphql/graphql.github.io/source/src/pages/faq/best-practices.mdx
— accessed 2026-08-09.</p>
        <quote p="55">"There's nothing that will prevent a GraphQL service from being versioned like any other
REST API. That said, GraphQL avoids versioning by design."</quote>
        <quote p="56">"GraphQL only returns the data that's explicitly requested. This means that you can add new
features (and all the associated types and fields) without creating a breaking change or
bloating results for existing queries."</quote>
        <p p="57">Source: the schema-design page in the same repo, and the mirrored `learn/best-practices` page.
URLs: https://raw.githubusercontent.com/graphql/graphql.github.io/source/src/pages/learn/schema-design.mdx
and http://chentsulin.github.io/graphql.github.io/learn/best-practices/ — both accessed 2026-08-09.</p>
        <quote p="58">"GraphQL takes a strong opinion on avoiding versioning by providing the tools for the
continuous evolution of a GraphQL schema."</quote>
        <quote p="59">"GraphQL only returns the data that's explicitly requested, so new capabilities can be added
via new types and new fields on those types without creating a breaking change."</quote>
        <quote p="60">"This has led to a common practice of always avoiding breaking changes and serving a
versionless API."</quote>
        <p p="61">Nullability is presented as a deliberate partial-failure mechanism — relevant because it is
the only place GraphQL puts tolerance on the *reader*:</p>
        <quote p="62">"In a GraphQL type system, every field is _nullable_ by default."</quote>
        <quote p="63">"By defaulting every field to _nullable_, any of these reasons may result in just that field
returned 'null' rather than having a complete failure for the request."</quote>
      </section>
      <section title="1.2.2 The deprecation mechanism (PRIMARY, the spec)">
        <p p="64">spec.graphql.org returns 403; the spec source was fetched from the specification repository.
URL: https://raw.githubusercontent.com/graphql/graphql-spec/main/spec/Section%203%20--%20Type%20System.md
— accessed 2026-08-09.</p>
        <quote p="65">"The `@deprecated` _built-in directive_ is used within the type system definition language
to indicate deprecated portions of a GraphQL service's schema, such as deprecated fields on
a type, arguments on a field, input fields on an input type, values of an enum type, or
directives. Deprecations include a reason for why it is deprecated, which is formatted using
Markdown syntax (as specified by CommonMark)."</quote>
        <fence lang="graphql" p="66">directive @deprecated(
  reason: String! = "No longer supported"
) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE | DIRECTIVE_DEFINITION</fence>
        <p p="67">**Q5 — deprecation lifecycle.** The mechanism carries **no duration and no expiry**.
`@deprecated` takes a `reason: String` and nothing else. There is no `removalDate`, no
`supportedUntil`, no machine-readable clock anywhere in the directive. This is a real and
under-appreciated gap: the GraphQL spec provides a way to *say* something is deprecated and
no way to say *until when*.</p>
        <p p="68">The enum result-coercion rule, which constrains the **server** only:</p>
        <quote p="69">"GraphQL services must return one of the defined set of possible values. If a reasonable
coercion is not possible they must raise an _execution error_."</quote>
        <quote p="70">"Enums are not references for a numeric value, but are unique values in their own right.
They may serialize as a string: the name of the represented value."</quote>
        <p p="71">Note what this does and does not say. It binds the server to *its own current schema*. It
says nothing about a client holding an *older* schema. That gap is §3.</p>
      </section>
      <section title="1.2.3 The criticisms (mixed)">
        <p p="72">**PRIMARY — the reference tooling's own classification.** `graphql-js` ships the canonical
breaking-change detector. URL:
https://raw.githubusercontent.com/graphql/graphql-js/16.x.x/src/utilities/findBreakingChanges.ts
— accessed 2026-08-09.</p>
        <p p="73">`BreakingChangeType` includes `VALUE_REMOVED_FROM_ENUM`. `DangerousChangeType` — a separate,
weaker category — includes `VALUE_ADDED_TO_ENUM`. So the reference implementation
does **not** call adding an enum value breaking; it invents a third category for it.</p>
        <p p="74">**PRIMARY — graphql-inspector, the widely used CI tool.** URL:
https://raw.githubusercontent.com/graphql-hive/graphql-inspector/master/packages/core/src/diff/changes/enum.ts
— accessed 2026-08-09. The reason string is the most quotable sentence in this entire report:</p>
        <quote p="75">"Adding an enum value may break existing clients that were not programming defensively
against an added case when querying an enum."</quote>
        <p p="76">Criticality: `CriticalityLevel.Dangerous` when the enum already existed;
`CriticalityLevel.NonBreaking` only when the enum is brand new. Removal is
`CriticalityLevel.Breaking`:</p>
        <quote p="77">"Removing an enum value will cause existing queries that use this enum value to error."</quote>
        <p p="78">**PRIMARY — GitHub's public GraphQL API policy.** URL:
https://docs.github.com/en/graphql/overview/breaking-changes — accessed 2026-08-09.
GitHub adopts the same three-way split *as published policy*, not just as tooling:</p>
        <quote p="79">"**Breaking:** Changes that will break existing queries to the GraphQL API. For example,
removing a field would be a breaking change."</quote>
        <quote p="80">"**Dangerous:** Changes that won't break existing queries but could affect the runtime
behavior of clients. Adding an enum value is an example of a dangerous change."</quote>
        <p p="81">**Q5 for GitHub — a real published duration and a real published schedule:**</p>
        <quote p="82">"We'll announce upcoming breaking changes at least three months before making changes to the
GraphQL schema, to give integrators time to make the necessary adjustments."</quote>
        <quote p="83">"Changes go into effect on the first day of a quarter (January 1st, April 1st, July 1st, or
October 1st)."</quote>
        <quote p="84">"For example, if we announce a change on January 15th, it will be made on July 1st."</quote>
        <p p="85">The page carries a dated forward schedule (entries observed for 2026-04-01, 2026-07-01,
2026-10-01 and 2027-01-01), i.e. the clock is published as data, not prose.</p>
        <p p="86">**SECONDARY (weak) — Nicolas Charpentier, "GraphQL Enums Are Unsafe", 2023-09-26.** URL:
https://charpeni.com/blog/graphql-enums-are-unsafe — accessed 2026-08-09. Independent
practitioner blog, not an organisation's engineering blog. Used only to characterise the
failure mode, which matches the primary sources above:</p>
        <quote p="87">"the frontend would hopefully end up with a type-error because the function/component
doesn't know how to handle `D`, but in most cases where we weren't defensive enough, it would
crash with an unexpected newly added value"</quote>
        <quote p="88">"if the frontend isn't defensive enough, it will crash"</quote>
        <p p="89">**PRIMARY (discussion) — graphql-spec issue #175, "Whole API versioning".** URL:
https://github.com/graphql/graphql-spec/issues/175 — accessed 2026-08-09. The filer's
argument against the versionless claim:</p>
        <quote p="90">"in real use, you are likely to make mistakes when designing your api, have requirements
change over time, or need to make changes for other reasons."</quote>
        <p p="91">**NOT FOUND:** a verbatim statement from Lee Byron or another GraphQL spec editor defending
the no-versioning decision in his own words. Searched: graphql-spec issues #175 and #134
(both fetched, accessed 2026-08-09 — #134's comment thread did not render for the fetcher and
#175 only *links* to his comment without reproducing it); the reactiflux Q&amp;A transcript
(fetched, contains nothing on versioning); web searches for Lee Byron + versioning + talk
transcripts. The claim that Meta deliberately avoids versioning **is** primary-sourced from
§1.1.3; the individual-attributed defence of it is not.</p>
        <p p="92">**SECONDARY, could not verify:** Marc-André Giroux, "How Should We Version GraphQL APIs?",
https://productionreadygraphql.com/blog/2019-11-06-how-should-we-version-graphql-apis/ —
fetch returned truncated content on 2026-08-09 and no quotes could be extracted. Listed for
re-fetch only; **no claim in this report rests on it.**</p>
      </section>
    </section>
    <section title="1.3 Google">
      <p p="93">Full detail with all verbatim quotes is in the Google subsection below. Structural note
first: `cloud.google.com/apis/design/compatibility` and `.../versioning` no longer exist as
independent documents — both 301-redirect into the AIP corpus
(→ https://google.aip.dev/180 and → https://google.aip.dev/185 respectively, verified
2026-08-09). AIP is now the single authoritative Google source.</p>
      <p p="94">**Q1 — hybrid: explicitly versioned at major level only, versionless within it.**
URL: https://google.aip.dev/185 — accessed 2026-08-09.</p>
      <quote p="95">"All Google API interfaces **must** provide a _major version number_, which is encoded at the
end of the protobuf package, and included as the first part of the URI path for REST APIs."</quote>
      <quote p="96">"However, unlike in traditional semantic versioning, Google APIs **must not** expose minor or
patch version numbers. For example, Google APIs use `v1`, not `v1.0`, `v1.1`, or `v1.4.2`.
From a user's perspective, major versions are updated in place with minor/patch equivalent
changes, and users receive new functionality without migration."</quote>
      <quote p="97">"A new major version of an API **must not** depend on a previous major version of the same
API."</quote>
      <quote p="98">"Different versions of the same API **must** be able to work at the same time within a single
client application for a reasonable transition period."</quote>
      <p p="99">URL: https://google.aip.dev/181 — accessed 2026-08-09.</p>
      <quote p="100">"When breaking changes become necessary, the API producer **should** create the next major
version of the API, and start a deprecation clock on the existing version."</quote>
      <p p="101">**Q2 — the burden is normatively on the SERVER.** URL: https://google.aip.dev/180 — accessed
2026-08-09.</p>
      <quote p="102">"Existing client code **must not** be broken by a service updating to a new minor or patch
release. Old clients **must** be able to work against newer servers (with the same major
version number)."</quote>
      <quote p="103">"1. Source compatibility: Code written against a previous version **must** compile against a
newer version, and successfully run with a newer version of the client library.
2. Wire compatibility: Code written against a previous version **must** be able to communicate
correctly with a newer server. In other words, not only are inputs and outputs compatible, but
the serialization and deserialization expectations continue to match.
3. Semantic compatibility: Code written against a previous version **must** continue to receive
what most reasonable developers would expect."</quote>
      <p p="104">An unusually strong clause — compatibility extends to *undocumented* behaviour:</p>
      <quote p="105">"Code will often depend on API behavior and semantics, _even when such behavior is not
explicitly supported or documented_. Therefore, APIs **must not** change visible behavior or
semantics in ways that are likely to break reasonable user code, as such changes will be seen
as breaking by those users."</quote>
      <p p="106">**There is no normative "clients MUST ignore unknown fields" rule anywhere in AIP-180.** The
full section list of AIP-180 was enumerated (Guidance; Adding components; Removing or renaming
components; Moving components between files; Moving into oneofs; Changing the type of fields;
Changing string length; Changing resource names; Semantic changes; Changing value format or
construction; Default values must not change; Serializing defaults; Further reading;
Rationale; Changelog) and contains no tolerant-reader clause. Client tolerance is *assumed*
to be a free property of the protobuf wire format.</p>
      <p p="107">The scope carve-out — this is directly relevant to the data-at-rest question, because it is
Google stating exactly which assumption its own guidance rests on:</p>
      <quote p="108">"This guidance assumes that APIs are intended to be called from a range of consumers,
written in multiple languages and with no control over how and when consumers update. Any
API which has a more limited scope (for example, an API which is only called by client code
written by the same team as the API producer, or deployed in a way which can enforce updates)
should carefully consider its own compatibility requirements."</quote>
      <p p="109">And an explicit humility clause:</p>
      <quote p="110">"**Important:** It is not always clear whether a change is compatible or not. The guidance
here **should** be treated as indicative, rather than as a comprehensive list of every possible
change."</quote>
      <p p="111">The prohibitions (all AIP-180, accessed 2026-08-09):</p>
      <quote p="112">"Existing components (interfaces, methods, messages, fields, enums, or enum values) **must
not** be removed from existing APIs in the same major version."
"**Important:** Renaming a component is semantically equivalent to 'remove and add'."
"Existing fields and messages **must not** have their type changed, even if the new type is
wire-compatible, because type changes alter generated code in a breaking way."
"Changing the default value is considered breaking and **must not** be done."
"APIs **must not** change the expected format or algorithm used to construct the value of an
existing field"</quote>
      <p p="113">**Q3 — enums: see §3, where Google's position is dissected.** The key quotes:</p>
      <p p="114">AIP-180 (accessed 2026-08-09):</p>
      <quote p="115">"In general, new components (interfaces, methods, messages, fields, enums, or enum values)
**may** be added to existing APIs in the same major version."
"For enum values specifically, be aware that it is possible that user code does not handle new
values gracefully."
"Enum values **may** be freely added to enums which are only used in request messages."
"Enums that are used in response messages or resources and which are expected to receive new
values **should** document this."
"Enum values still **may** be added in this situation; however, appropriate caution **should**
be used."</quote>
      <p p="116">AIP-216 (states), https://google.aip.dev/216 — accessed 2026-08-09. This is the most candid
paragraph Google publishes on the subject:</p>
      <quote p="117">"Even though adding states to an existing states enum _can_ break existing user code, adding
states is not considered a breaking change."
"Consider a state with only two values: `ACTIVE` and `DELETED`. A user may add code that checks
`if state == ACTIVE`, and in the else cases simply assumes the resource is deleted. If the API
later adds a new state for another purpose, that code will break."
"We ultimately can not control this behavior, but API documentation **should** actively
encourage users to code against state enums with the expectation that they may receive new
values in the future."</quote>
      <p p="118">AIP-126, https://google.aip.dev/126 — accessed 2026-08-09. The **actual** mitigation is a
budget, not a mechanism:</p>
      <quote p="119">"Enums can be more accessible and readable than strings or booleans in many cases, but they do
add overhead when they change. Therefore, enums **should** receive new values infrequently.
While the definition of 'infrequently' may change based on individual use cases, a good rule of
thumb is no more than once a year. For enums that change frequently, the API **should** use a
string and document the format."</quote>
      <quote p="120">"For enumerated values where the set of allowed values changes frequently, APIs **should** use
a `string` field instead, and **must** document the allowed values."</quote>
      <quote p="121">"Enums **should** document whether the enum is frozen or they expect to add values in the
future."</quote>
      <quote p="122">"The first value of the enum **should** be the name of the enum itself followed by the suffix
`_UNSPECIFIED`."</quote>
      <quote p="123">"An exception to this rule is if there is a clearly useful zero value. In particular, if an
enum needs to present an `UNKNOWN`, it is usually clearer and more useful for it to be a zero
value rather than having both."</quote>
      <p p="124">AIP-216 further specifies that the zero value means *unset*, not *unrecognised*:</p>
      <quote p="125">"Resources **should not** provide an unspecified state to users, and this value **should not**
actually be used."</quote>
      <p p="126">**Q4 — forced upgrade: Google provides no true force-update mechanism.**
URL: https://developer.android.com/guide/playcore/in-app-updates — accessed 2026-08-09.</p>
      <quote p="127">"Immediate updates are fullscreen UX flows that require the user to update and restart the app
in order to continue using it."
"This UX flow is best for cases where an update is critical to the core functionality of your
app."</quote>
      <p p="128">But the same corpus instructs developers to handle refusal, which proves it is not
enforceable. URL: https://developer.android.com/guide/playcore/in-app-updates/kotlin-java —
accessed 2026-08-09:</p>
      <quote p="129">"Your app should be able to handle cases where a user declines the update or cancels the
download."
"If possible, let the user continue without the update and prompt them again later."
"If your app can't function without the update, consider displaying an informative message
before restarting the update flow or prompting the user to close the app."</quote>
      <p p="130">Google's own recommended fallback for an app that cannot function without the update is
**to ask the user to close it**. There is no server-side minimum-version enforcement and no
kill switch in the platform.</p>
      <p p="131">**NOT FOUND:** any Google-provided server-side mechanism to block an outdated client or
mandate a minimum app version. Searched: developer.android.com, support.google.com,
android-developers.googleblog.com for force update / minimum version / server enforce /
declined. Only developer-community forum threads (SECONDARY, non-authoritative) discuss it,
and they conclude the developer must build it.</p>
      <p p="132">**Q5 — deprecation lifecycle: 12 months, contractually.**
URL: https://cloud.google.com/terms/ §1.4(e) — accessed 2026-08-09.</p>
      <p p="133">Verified by a second independent fetch on 2026-08-09; the complete sentence is:</p>
      <quote p="134">"Google will notify Customer at least 12 months before: (i) discontinuing any Service (or
associated material functionality) unless Google replaces such discontinued Service or
functionality with a materially similar Service or functionality; or (ii) significantly
modifying a Customer-facing Google API in a backwards-incompatible manner."</quote>
      <p p="135">Note that the 12-month clock covers **both** shutdown and backwards-incompatible modification.
Pre-GA is excluded (https://cloud.google.com/terms/deprecation — accessed 2026-08-09):</p>
      <quote p="136">"Any versions, features, or functionality of the Services below labeled 'Early Access',
'Alpha', or 'Beta' are excluded from the Deprecation Policy."</quote>
      <p p="137">Engineering guidance, AIP-185 and AIP-181 — accessed 2026-08-09:</p>
      <quote p="138">"The beta channel's functionality **may** be removed after it has been deprecated for a
sufficient period; we recommend 180 days."
"An alpha release **may** be shut down at any time, while a beta release **should** allow users
a reasonable transition period; we recommend 180 days."
"Beta components **should** be time-boxed and promoted to stable if no issues are found in the
specified timeframe … a good rule of thumb is 90 days."</quote>
      <quote p="139">"**Important:** Making an in-place breaking change in a stable API is considered an extreme
course of action, and should be treated with equal or greater gravity as creating a new major
version. For example, at Google, this requires the approval of the API Governance team."</quote>
      <p p="140">**Q6 — CI compatibility checking: Google publishes NO officially supported gate.** This is a
firm negative, verified structurally. The API Linter's rules directory was enumerated via the
GitHub API (https://api.github.com/repos/googleapis/api-linter/contents/rules — accessed
2026-08-09); the listing contains `aip0121 … aip0235, aip4232, internal` and **`aip0180` is
absent**. `aip0126` and `aip0216` exist but enforce naming style, not evolution safety.</p>
      <quote p="141">"Not every piece of AIP guidance is able to be expressed as lint rules"
"The linter should be used as a useful tool, but not as a substitute for reading and
understanding API guidance."
— https://linter.aip.dev/ — accessed 2026-08-09</quote>
      <p p="142">A breaking-change detector exists but is disclaimed:</p>
      <quote p="143">"This repository contains the source code of a breaking change detector in proto level, which
takes API proto definition files and detects the unintended breaking changes in minor versions
updates."
"This is not an officially supported Google project."
— https://github.com/googleapis/proto-breaking-change-detector — accessed 2026-08-09</quote>
      <p p="144">Repo state via GitHub API (accessed 2026-08-09): not archived, 23 stars, 38 open issues, no
description, last push 2026-05-20. Live but marginal.</p>
    </section>
    <section title="1.4 Kubernetes — the counter-authority (PRIMARY)">
      <p p="145">Kubernetes is not in the original subject list but earns a section, because it is the one
large-scale published corpus that (a) flatly contradicts Google on the enum question and
(b) governs **data at rest**, since Kubernetes objects are persisted in etcd and must be
readable by later code. That combination makes it the closest published analogue to the
data-at-rest case in §4.</p>
      <p p="146">URL: https://raw.githubusercontent.com/kubernetes/community/main/contributors/devel/sig-architecture/api_changes.md
— accessed 2026-08-09.</p>
      <p p="147">**Q3 — the enum ruling, and it is the opposite of Google's:**</p>
      <p p="148">The complete paragraph, from the section **"Backward compatibility gotchas"**, verified verbatim
by two independent fetches on 2026-08-09:</p>
      <quote p="149">"Enumerated values cause similar challenges. Adding a new value to an enumerated set is *not* a
compatible change. Clients which assume they know how to handle all possible values of a given
field will not be able to handle the new values. However, removing a value from an enumerated set
*can* be a compatible change, if handled properly (treat the removed value as deprecated but
allowed). For enumeration-like fields that expect to add new values in the future, such as
`reason` fields, document that expectation clearly in the API field description in the first
release the field is made available, and describe how clients should treat an unknown value.
Clients should treat such sets of values as potentially open-ended."</quote>
      <p p="150">**Q2 — six normative compatibility rules, quoted in full:**</p>
      <quote p="151">"1. Any API call (e.g. a structure POSTed to a REST endpoint) that succeeded before your change
must succeed after your change.
2. Any API call that does not use your change must behave the same as it did before your change.
3. Any API call that uses your change must not cause problems (e.g. crash or degrade behavior)
when issued against an API servers that do not include your change.
4. It must be possible to round-trip your change (convert to different API versions and back)
with no loss of information.
5. Existing clients need not be aware of your change in order for them to continue to function
as they did previously, even when your change is in use.
6. It must be possible to rollback to a previous version of API server that does not include
your change and have no impact on API objects which do not use your change."</quote>
      <p p="152">Definition of compatible:</p>
      <quote p="153">"does not change existing semantics, including: the semantic meaning of default values *and
behavior*; interpretation of existing API types, fields, and values; which fields are required
and which are not; mutable fields do not become immutable; valid values do not become invalid;
explicitly invalid values do not become valid"</quote>
      <p p="154">**Q1 / Q5 — explicit versions with published durations.**
URL: https://kubernetes.io/docs/reference/using-api/deprecation-policy/ — accessed 2026-08-09.</p>
      <quote p="155">"**Rule #1: API elements may only be removed by incrementing the version of the API group.**"
"Once an API element has been added to an API group at a particular version, it can not be
removed from that version or have its behavior significantly changed, regardless of track."</quote>
      <quote p="156">"**Rule #2: API objects must be able to round-trip between API versions in a given release
without information loss**, with the exception of whole REST resources that do not exist in some
versions."</quote>
      <p p="157">**Rule #4a**, verified verbatim by a second fetch on 2026-08-09:</p>
      <quote p="158">"**Rule #4a: API lifetime is determined by the API stability level**
- GA API versions may be marked as deprecated, but must not be removed within a major version of
Kubernetes
- Beta API versions are deprecated no more than 9 months or 3 minor releases after introduction
(whichever is longer), and are no longer served 9 months or 3 minor releases after deprecation
(whichever is longer)
- Alpha API versions may be removed in any release without prior deprecation notice"</quote>
      <p p="159">Enumerated values are held to the same standard as resources:</p>
      <quote p="160">"As with whole REST resources and fields thereof, a constant value which was supported in API
v1 must exist and function until API v1 is removed."</quote>
      <p p="161">**The data-at-rest clause** — the single most transferable sentence found in this research:</p>
      <quote p="162">"no API versions that have been persisted to storage may be removed. Serving REST endpoints for
those versions may be disabled (subject to the deprecation timelines in this document), but the
API server must remain capable of decoding/converting previously persisted data from storage."</quote>
      <p p="163">Read that carefully. Kubernetes draws exactly the distinction §4 needs: you may stop *serving*
an old version, but you may never stop *reading* it, because the data outlived the endpoint.</p>
    </section>
    <section title="1.5 Stripe — the explicit-versioning counter-example (PRIMARY)">
      <p p="164">Included because it is the best-documented large-scale system that chose the **opposite** of
the versionless strategy, and because its mechanism (pin the version at write time) is one of
the few that survives translation to data at rest.</p>
      <p p="165">URL: https://stripe.com/blog/api-versioning — accessed 2026-08-09.</p>
      <quote p="166">"rolling versions that are named with the date they're released (for example, `2017-05-24`)"</quote>
      <quote p="167">"The first time a user makes an API request, their account is automatically pinned to the most
recent version available, and from then on, every API call they make is assigned that version
implicitly."</quote>
      <quote p="168">"To date, we've maintained compatibility with every version of our API since the company's
inception in 2011."</quote>
      <p p="169">**Q5 — the published duration is, in effect, forever.** No sunset is stated.</p>
      <p p="170">URL: https://docs.stripe.com/upgrades — accessed 2026-08-09. The published list of
backward-compatible changes, verbatim and complete:</p>
      <quote p="171">"Stripe considers the following changes to be backward-compatible:
- Adding new API resources.
- Adding new optional request parameters to existing API methods.
- Adding new properties to existing API responses.
- Changing the order of properties in existing API responses.
- Changing the length or format of opaque strings, such as object IDs, error messages, and
other human-readable strings.
- Adding new event types.
- Make sure that your webhook listener gracefully handles unfamiliar event types."</quote>
      <p p="172">Two observations, both load-bearing:</p>
      <list ordered="true" p="173">
        <item>**Adding an enum value is NOT on Stripe's backward-compatible list.** Stripe's list is
   materially narrower than Google's. The one closed-vocabulary growth case it does bless —
   new event types — comes with an explicit client-side obligation attached in the same
   bullet.</item>
        <item>**"Make sure that your webhook listener gracefully handles unfamiliar event types"** is the
   clearest normative tolerant-reader instruction found in any vendor's docs. It is stated
   *at the point of the change that requires it*, not in a general principles section.</item>
      </list>
      <quote p="174">"Each major release, such as Basil, includes changes that aren't backward-compatible with
previous releases. Upgrading to a new major release can require updates to existing code. Each
monthly release includes only backward-compatible changes, and uses the same name as the last
major release. You can safely upgrade to a new monthly release without breaking any existing
code."</quote>
    </section>
    <section title="1.6 IETF — the normative statements nobody in the mobile world cites (PRIMARY)">
      <p p="175">These two RFCs are the only genuinely *normative*, standards-track answers to Q2 found in
this research, and they point in opposite directions from each other. That tension is
itself a finding.</p>
      <section title="RFC 6709, &quot;Design Considerations for Protocol Extensions&quot;">
        <p p="176">URL: https://www.rfc-editor.org/rfc/rfc6709.html — accessed 2026-08-09.</p>
        <p p="177">§4.2, on reserved fields — this is the clearest published statement that **strictness is the
bug**, not the safety measure:</p>
        <quote p="178">"It is good practice to specify the value to be inserted in such a field by the sender
(typically zero) and the action to be taken by the receiver when seeing some other value
(typically no action)."</quote>
        <quote p="179">"A common mistake of inexperienced protocol implementers is to think that 'MBZ' means that
it's their software's job to verify that the value of the field is zero on reception and reject
the packet if not. This is a mistake, and such software will fail when it encounters future
versions of the protocol where these previously reserved fields are given new defined meanings."</quote>
        <quote p="180">"Similarly, protocols should carefully specify how receivers should react to unknown extensions
(headers, TLVs, etc.), such that failures occur only when that is truly the intended outcome."</quote>
        <p p="181">The actual formulation of the must-ignore rule, verified against the plain-text RFC
(https://www.rfc-editor.org/rfc/rfc6709.txt — accessed 2026-08-09), §4.2:</p>
        <quote p="182">"'MBZ', to be read as, 'Must Be Zero on transmission, Must Be Ignored on reception.'"</quote>
        <p p="183">§4.7, "Handling of Unknown Extensions" — the opening statement:</p>
        <quote p="184">"IETF protocols have utilized several techniques for the handling of unknown extensions. One
technique (often used for vendor-specific extensions) is to specify that unknown extensions be
'silently discarded'."</quote>
        <p p="185">and, on when tolerance is *wrong*:</p>
        <quote p="186">"In order to ensure that a recipient supports an extension, a recipient encountering an unknown
extension may be required to explicitly reject it and to return an error, rather than ignoring
the unknown extension and proceeding with the remainder of the message."</quote>
        <p p="187">**§A.3, on TLS, is the concrete example of the granularity principle** — the same protocol
being tolerant in one place and strict in another, deliberately:</p>
        <quote p="188">"Implementations are supposed to ignore unknown record types but to reject unknown handshake
messages."</quote>
        <p p="189">§4.1 on version negotiation:</p>
        <quote p="190">"Protocols generally do not need any version-negotiation mechanism more complicated than the
mechanisms described here."</quote>
      </section>
      <section title="RFC 9413, &quot;Maintaining Robust Protocols&quot;">
        <p p="191">URL: https://www.rfc-editor.org/rfc/rfc9413.html — accessed 2026-08-09.</p>
        <p p="192">This is the IETF formally walking back Postel's Law, and it is the strongest published
counter-argument to "just make the reader tolerant":</p>
        <p p="193">The complete Abstract, verified verbatim against the plain-text RFC
(https://www.rfc-editor.org/rfc/rfc9413.txt — accessed 2026-08-09):</p>
        <quote p="194">"The main goal of the networking standards process is to enable the long-term interoperability
of protocols. This document describes active protocol maintenance, a means to accomplish that
goal. By evolving specifications and implementations, it is possible to reduce ambiguity over
time and create a healthy ecosystem.

The robustness principle, often phrased as 'be conservative in what you send, and liberal in
what you accept', has long guided the design and implementation of Internet protocols. However,
it has been interpreted in a variety of ways. While some interpretations help ensure the health
of the Internet, others can negatively affect interoperability over time. When a protocol is
actively maintained, protocol designers and implementers can avoid these pitfalls."</quote>
        <p p="195">All three of the following verified verbatim against the plain-text RFC on 2026-08-09:</p>
        <quote p="196">"However, an interpretation that advocates for tolerating unexpected inputs is no longer
considered best practice in all scenarios."</quote>
        <quote p="197">"Time and experience show that negative consequences to interoperability accumulate over time if
implementations silently accept faulty input."</quote>
        <quote p="198">"Tolerating unexpected input instead conceals problems, making it harder, if not impossible, to
fix them later."</quote>
        <p p="199">The document describes a "pathological feedback cycle" in which tolerated errors become
entrenched, buggy behaviour becomes "de facto standard", and implementers are forced to be
"bug-for-bug compatible". Its §5.1 is titled **"Virtuous Intolerance"** (the phrase appears as
a section heading, not in body text); the body states the idea as:</p>
        <quote p="200">"Choosing to generate fatal errors for unspecified conditions instead of attempting error
recovery can ensure that faults receive attention. This intolerance can be harnessed to reduce
occurrences of aberrant implementations."</quote>
        <p p="201">Coupled with:</p>
        <quote p="202">"Protocol designers are strongly encouraged to continue to maintain and evolve protocol
specifications beyond their initial inception and definition."</quote>
        <p p="203">**The tension worth internalising:** RFC 6709 says *ignore what you do not understand or you
will break on future versions*. RFC 9413 says *ignoring what you do not understand hides bugs
and ossifies the protocol*. Both are right, and they are reconciled by **granularity**: be
tolerant of things the format explicitly designated as extension points, and strict about
everything else. A format that does not mark its extension points forces the reader to guess,
and both failure modes then become available at once.</p>
      </section>
      <section title="OpenID Connect Core 1.0 — the normative MUST-IGNORE for a JSON document">
        <p p="204">URL: https://openid.net/specs/openid-connect-core-1_0.html — accessed 2026-08-09. Included
because it is the clearest *normative* tolerant-reader rule found for a JSON payload, and
because of the asymmetry it exposes.</p>
        <p p="205">§2, on the ID Token (a JSON object):</p>
        <quote p="206">"Any Claims used that are not understood MUST be ignored."</quote>
        <p p="207">§3.1.3.3:</p>
        <quote p="208">"Clients SHOULD ignore unrecognized response parameters."</quote>
        <p p="209">§3.1.2.1:</p>
        <quote p="210">"Scope values used that are not understood by an implementation SHOULD be ignored."</quote>
        <p p="211">**Now the asymmetry, and it is the whole enum problem in one specification.** For `display`
and `prompt` — which are closed vocabularies, i.e. enums — the same spec refuses to mandate
anything, in §3.1.2.6:</p>
        <quote p="212">"If an OP receives a display value outside the set defined above that it does not understand,
it MAY return an error or it MAY ignore it."</quote>
        <quote p="213">"If an OP receives a prompt value outside the set defined above that it does not understand, it
MAY return an error or it MAY ignore it."</quote>
        <p p="214">A mature, heavily deployed, standards-track JSON specification therefore says **MUST ignore**
for an unknown *member* and **MAY error or MAY ignore** for an unknown *enum value*. The
unknown-field problem is considered solved and is legislated; the unknown-enum-value problem
is considered unsolvable and is left to the implementer. That is not an oversight — it recurs
in every corpus surveyed, and §3 is about why.</p>
      </section>
    </section>
    <section title="1.7 Data-at-rest precedents (PRIMARY) — what formats actually do when they cannot negotiate">
      <p p="215">Gathered specifically to answer Q7 with evidence rather than opinion. These are the published
practices of formats that are **written as files and read by consumers the publisher does not
control** — the actual shape of the questioner's problem.</p>
      <section title="npm `package-lock.json` — an explicit integer version in the file">
        <p p="216">URL: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json — accessed 2026-08-09.</p>
        <quote p="217">"No version provided: an 'ancient' shrinkwrap file from a version of npm prior to npm v5."
"`1`: The lockfile version used by npm v5 and v6."
"`2`: The lockfile version used by npm v7 and v8. Backwards compatible to v1 lockfiles."
"`3`: The lockfile version used by npm v9 and above. Backwards compatible to npm v7."</quote>
        <p p="218">And the tolerant-reader rule, stated for a **file**:</p>
        <quote p="219">"npm will always attempt to get whatever data it can out of a lockfile, even if it is not a
version that it was designed to support."</quote>
        <p p="220">This is the closest published analogue to the questioner's case, and note what it does: it
uses an **explicit version integer**, not versionless additive evolution. The format that
most resembles "JSON in a git repo read by tools I do not control" chose the *opposite* of
the mobile playbook.</p>
      </section>
      <section title="Avro object container files — embed the writer&apos;s schema in the file">
        <p p="221">Source: Martin Kleppmann, "Schema evolution in Avro, Protocol Buffers and Thrift",
https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
— accessed 2026-08-09. (Author's own technical blog; the author later wrote *Designing
Data-Intensive Applications*. Treat as **strong secondary** — expert-authored, not an
organisation's official position.)</p>
        <quote p="222">"In real life, data is always in flux. The moment you think you have finalised a schema,
someone will come up with a use case that wasn't anticipated, and wants to 'just quickly add a
field.'"</quote>
        <quote p="223">"Although you need to know the exact schema with which the data was written (the writer's
schema), that doesn't have to be the same as the schema the consumer is expecting (the reader's
schema)."</quote>
        <quote p="224">"Object container files handle this case: they just include the schema once at the beginning of
the file, and the rest of the file can be decoded with that schema."</quote>
        <p p="225">Contrast with RPC, in the same source:</p>
        <quote p="226">"it's probably too much overhead to send the schema with every request and response"</quote>
        <p p="227">The mechanism is: **the file carries its own schema**. Reader/writer reconciliation then
happens locally, with no negotiation and no live server. This is the only technique found
that fully solves the data-at-rest problem, and it costs bytes in every file.</p>
      </section>
      <section title="schema.org — a published vocabulary read by uncontrolled third parties">
        <p p="228">URL: https://schema.org/docs/howwework.html — accessed 2026-08-09. This is structurally the
closest match to the questioner's case of any *organisation* studied: a vocabulary published
as data, consumed by arbitrary tools worldwide, with no ability to negotiate or force-upgrade.</p>
        <p p="229">Verified verbatim by two independent fetches on 2026-08-09:</p>
        <quote p="230">"It is exceptionally rare for a property, type or enumerated value to be deleted/removed
without leaving it in the system as 'supersededBy' another."</quote>
        <p p="231">**Note that the rule names *enumerated value* explicitly, alongside property and type.**
schema.org is the one organisation in this study whose consumer population most closely matches
the questioner's — arbitrary, uncounted, uncontactable third-party tools — and its policy on
closed-vocabulary members is the strictest found anywhere: never remove, always tombstone with
a forwarding pointer. It says nothing about never *adding*, which is consistent with everyone
else's silence, but the never-remove half is stated more firmly here than in any API corpus.</p>
        <quote p="232">"Consumers of schema.org data can generally rely on schema.org term meanings not changing
dramatically; however term definitions often evolve gradually over time, to accommodate new
usage scenarios or to improve usability."</quote>
        <quote p="233">"For general use, publishers and consumers are encouraged to use the latest release and to use
simple non-versioned schema.org URLs such as 'https://schema.org/Place' in structured data
applications."</quote>
        <quote p="234">"However there are settings in which more precise versioning is important."</quote>
        <quote p="235">"Each release has a name that is assigned upon publication (e.g. '2.1')."</quote>
        <quote p="236">"Schema.org also provides dated snapshots of each release, including both human and machine
readable definitions of the schema.org core vocabulary."</quote>
        <p p="237">Note the hybrid: **unversioned URLs for general use, dated snapshots for when you need
precision, and a hard never-delete rule with an explicit `supersededBy` redirect.** The
never-delete-plus-tombstone rule is the practice that transfers most cleanly.</p>
      </section>
    </section>
    <section title="1.8 Q6 in depth — the compatibility-checking tools (PRIMARY)">
      <p p="238">This is the question with the most concrete, verifiable answer, so it gets full treatment.
The decisive axis is not "does it exist" — several do — but **what each tool needs in order to
work**: a live server, an observable client population, or nothing but two schema files.</p>
      <section title="1.8.1 Buf — `buf breaking` (Protobuf). Pure static diff.">
        <p p="239">URL: https://buf.build/docs/breaking/ — accessed 2026-08-09.</p>
        <quote p="240">"`buf breaking` compares the current version of your Protobuf schema against a past version
and reports any changes that would break clients, servers, or the code generated from those
schemas."</quote>
        <p p="241">The four categories form a strictness ladder:</p>
        <quote p="242">"**FILE:** Detects breakage to generated source code on a per-file basis."
"**PACKAGE:** Detects breakage to generated source code on a per-package basis."
"**WIRE_JSON:** Detects breakage to the binary wire format or JSON encoding."
"**WIRE:** Detects breakage to the binary wire format only."
"Passing a stricter category implies passing every looser one: schemas that pass `FILE` also
pass `PACKAGE`, `WIRE_JSON`, and `WIRE`."</quote>
        <p p="243">**The enum answer, and it is a negative finding: buf has NO rule that forbids ADDING an enum
value, at any strictness level.** The complete set of enum-value rules is
`ENUM_VALUE_NO_DELETE`, `ENUM_VALUE_NO_DELETE_UNLESS_NAME_RESERVED`,
`ENUM_VALUE_NO_DELETE_UNLESS_NUMBER_RESERVED`, `ENUM_VALUE_SAME_NAME`. No `NO_ADD` variant
exists. (https://buf.build/docs/breaking/rules/ — accessed 2026-08-09.)</p>
        <p p="244">Selected rules verbatim from that page (accessed 2026-08-09):</p>
        <quote p="245">`ENUM_VALUE_NO_DELETE` (FILE, PACKAGE): "This checks that no enum value is deleted. Deleting
an enum value results in the corresponding value or field being deleted from the generated
source code, which could be referenced."</quote>
        <quote p="246">`ENUM_VALUE_NO_DELETE_UNLESS_NUMBER_RESERVED` (WIRE_JSON, WIRE): "This checks that no enum
value is deleted without reserving the number. Though deleting an enum value isn't directly a
wire-breaking change, reusing these numbers in the future is likely to result in bugs."</quote>
        <quote p="247">`ENUM_SAME_TYPE` (FILE, PACKAGE): "This checks that an enum doesn't change from open to closed
or vice versa, because whether an enum is open or closed can impact code generation. Enums in
`proto2` files are closed, which means that unrecognized values result in the field being unset
(the actual value is stored with other unrecognized fields). Enums in `proto3` files are open,
which means that values not defined in the schema are accepted."</quote>
        <quote p="248">`ENUM_VALUE_SAME_NAME` (FILE, PACKAGE, WIRE_JSON): "This checks that a given enum value has the
same name for each enum value number. For example You can't change `FOO_ONE = 1` to
`FOO_TWO = 1`. Doing so results in potential JSON incompatibilities and broken source code."</quote>
        <p p="249">**Needs no live server and no client telemetry.** `--against` accepts a local git ref, a
remote git URL, a BSR module, or an archive (https://buf.build/docs/breaking/usage/ — accessed
2026-08-09). Default category when unconfigured is `FILE`. A server-side variant exists
(https://buf.build/docs/bsr/checks/breaking/ — accessed 2026-08-09): "the BSR enforces one of
two breaking-change rule sets on commits that try to advance the default label", and "On
`buf push`, the BSR's policy always wins".</p>
      </section>
      <section title="1.8.2 Protobuf proto2 — the counter-ruling that undoes &quot;adding an enum value is safe&quot;">
        <p p="250">URL: https://protobuf.dev/programming-guides/proto2/ — accessed 2026-08-09.</p>
        <quote p="251">"A second issue with required fields appears when someone adds a value to an enum. In this
case, the unrecognized enum value is treated as if it were missing, which also causes the
required value check to fail."</quote>
        <quote p="252">"Because the default value for enums is the first defined enum value, take care when adding a
value to the beginning of an enum value list."</quote>
        <p p="253">Compare to https://protobuf.dev/programming-guides/proto3/ — accessed 2026-08-09:</p>
        <quote p="254">"Adding additional values to an enum is safe."</quote>
        <quote p="255">"Be aware that client code may treat them differently when the message is deserialized: for
example, unrecognized proto3 enum values will be preserved in the message, but how this is
represented when the message is deserialized is language-dependent."</quote>
        <p p="256">**And the conformance table on https://protobuf.dev/programming-guides/enum/ (accessed
2026-08-09) records that C++, C#, Java, Kotlin, Go, JSPB, Ruby and Dart "all have known
conformance gaps" on open/closed enum handling.** The spec's ruling is not necessarily what
your runtime does.</p>
        <p p="257">Protobuf's own best-practices page states the framing assumption behind all of this, and it is
worth quoting because it is the assumption a git-published file *also* satisfies:
URL: https://protobuf.dev/best-practices/dos-donts/ — accessed 2026-08-09.</p>
        <quote p="258">"Clients and servers are never updated at exactly the same time - even when you try to update
them at the same time. One or the other may get rolled back. Don't assume that you can make a
breaking change and it'll be okay because the client and server are in sync."</quote>
        <p p="259">And the closed-enum consequence, stated plainly:</p>
        <quote p="260">"When new values are added to an enum, old clients will see the field as unset and the getter
will return the default value or the first-declared value if no default exists."</quote>
        <quote p="261">"It may be tempting to declare this default as a semantically meaningful value but as a general
rule, do not, to aid in the evolution of your protocol as new enum values are added over time."</quote>
        <p p="262">Two adjacent rules from the same page that transfer well to file formats:</p>
        <quote p="263">"**Do Reserve Numbers for Deleted Enum Values** … When you delete an enum value that's no longer
used, reserve its number so that no one accidentally re-uses it in the future."</quote>
        <quote p="264">"**Don't Use Booleans for Something That Has Two States Now, but Might Have More Later** … The
future flexibility of using an enum is often worth it, even if it only has two values when it is
first introduced."</quote>
      </section>
      <section title="1.8.3 Confluent Schema Registry — compatibility modes">
        <p p="265">URL: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
— accessed 2026-08-09.</p>
        <quote p="266">BACKWARD: "consumers using the new schema can read data produced with the last schema"
FORWARD: "data produced with a new schema can be read by consumers using the last schema"
FULL: "schemas are both backward and forward compatible"</quote>
        <p p="267">Transitive variants check "against all previously registered schemas" rather than only the
latest. The operationally decisive passage, quoted in full:</p>
        <quote p="268">"The configured compatibility type has an implication on the order for upgrading client
applications … Depending on the compatibility type:
- `BACKWARD` or `BACKWARD_TRANSITIVE`: there is no assurance that consumers using older schemas
can read data produced using the new schema. Therefore, upgrade all consumers before you start
producing new events.
- `FORWARD` or `FORWARD_TRANSITIVE`: there is no assurance that consumers using the new schema
can read data produced using older schemas. Therefore, first upgrade all producers to using the
new schema and make sure the data already produced using the older schemas are not available to
consumers, then upgrade the consumers.
- `FULL` or `FULL_TRANSITIVE`: there are assurances that consumers using older schemas can read
data produced using the new schema and that consumers using the new schema can read data
produced using older schemas. Therefore, you can upgrade the producers and consumers
independently.
- `NONE`: compatibility checks are disabled. Therefore, you need to be cautious about when to
upgrade clients."</quote>
        <p p="269">**Read the FORWARD bullet against the data-at-rest case.** Confluent's own prescription for
forward compatibility includes *"make sure the data already produced using the older schemas
are not available to consumers"* — i.e. delete or hide the old data. In a git repository that
option does not exist; history is the point.</p>
        <p p="270">**Q3 for Confluent — NOT FOUND, and this is a substantive negative.** Both the Platform and
Cloud schema-evolution pages were fetched and searched for the string "enum"
(https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
and https://docs.confluent.io/cloud/current/sr/fundamentals/schema-evolution.html — both
accessed 2026-08-09); **the word does not appear on either page.** Confluent's per-format
tables concern fields, union/oneof variants, and scalar widening. For Avro it delegates to
the Avro spec's Schema Resolution section.</p>
        <p p="271">**Needs a server only optionally.** `schema-registry:test-compatibility` is "used to read
schemas from the local file system and test them for compatibility against the Schema Registry
servers"; `schema-registry:test-local-compatibility` "tests compatibility of a local schema with
other existing local schemas during development and testing phases" —
"Before the addition of `schema-registry:test-local-compatibility`, if you wanted to check
compatibility of a new schema you had to connect to the Schema Registry."
(https://docs.confluent.io/platform/current/schema-registry/develop/maven-plugin.html —
accessed 2026-08-09.) **No client telemetry required in any mode.**</p>
        <p p="272">**Gap closed by the issue tracker (PRIMARY).** Confluent's own engineer states the ruling that
the docs omit. URL: https://github.com/confluentinc/schema-registry/issues/601 — accessed
2026-08-09. Ewen Cheslack-Postava (Confluent), 2017-08-04:</p>
        <quote p="273">"Currently if you add a new value to an enum and register the updated schema against a subject
with forward compatibility set, it will pass the compatibility check."</quote>
        <quote p="274">"However, this is an incompatible change since data written with the new enum symbol would not
be readable by the earlier schemas."</quote>
        <quote p="275">"SR just relies on the Avro compatibility checks"</quote>
        <p p="276">So: **adding an enum symbol IS a forward-incompatible change, and Confluent's checker used to
let it through anyway.** That is a compatibility gate silently failing open on precisely the
question under study.</p>
        <quote p="277">⚠️ **LOW CONFIDENCE — RE-FETCH BY HAND.** Two independent fetches of the Confluent
schema-evolution page returned *inconsistent* per-format compatibility tables (the Protobuf
"Remove union/oneof variant" row differed between fetches). The BACKWARD/FORWARD definitions
and the upgrade-ordering block above were stable across both fetches and are safe to rely on.
The per-format tables are **not** transcribed here for that reason.</quote>
      </section>
      <section title="1.8.4 Apache Avro — the only spec with a first-class enum evolution mechanism">
        <p p="278">URL: https://avro.apache.org/docs/1.12.0/specification/ (identical text at .../1.11.1/) —
accessed 2026-08-09.</p>
        <quote p="279">"_default_: A default value for this enumeration, used during resolution when the reader
encounters a symbol from the writer that isn't defined in the reader's schema (optional). The
value provided here must be a JSON string that's a member of the symbols array."</quote>
        <p p="280">The resolution rule, verbatim — this is the sharpest single sentence in the whole enum
question:</p>
        <quote p="281">"if both are enums: if the writer's symbol is not present in the reader's enum and the reader
has a default value, then that value is used, otherwise **an error is signalled**."</quote>
        <quote p="282">"A reader of Avro data, whether from an RPC or a file, can always parse that data because the
original schema must be provided" with the data.</quote>
        <p p="283">Verified against the reference implementation (PRIMARY, source code):
https://raw.githubusercontent.com/apache/avro/main/lang/java/avro/src/main/java/org/apache/avro/SchemaCompatibility.java
— accessed 2026-08-09. The incompatibility constant is `MISSING_ENUM_SYMBOLS`, and
`checkReaderEnumContainsAllWriterEnumSymbols()` implements exactly the spec rule: it computes
`writer.getEnumSymbols() − reader.getEnumSymbols()`, and if that set is non-empty it returns
`compatible()` **only if** `reader.getEnumDefault() != null` and the reader's symbol list
contains that default; otherwise `incompatible(MISSING_ENUM_SYMBOLS, …)`.</p>
        <p p="284">**The operational fact that matters most in this entire report:** the escape hatch must be
present in the **old** artifact. Avro's enum `default` protects a reader only if that reader's
schema *already declared it* before the new symbol appeared. No later schema change can
retrofit protection onto readers already in the wild. Enum `default` landed in Avro 1.9.0 via
AVRO-1340 (https://issues.apache.org/jira/browse/AVRO-1340 — ASF JIRA, project record;
accessed 2026-08-09), whose stated motivation was that "it was difficult to use enums because
you could never add an enum value and keep old readers compatible."</p>
        <p p="285">**Avro is explicitly a data-at-rest format.** Object Container Files contain "a schema, and all
objects stored in the file must be written according to that schema", with "the schema of
objects stored in the file, as JSON data" as required header metadata (1.12.0 spec, accessed
2026-08-09). The writer's schema travels with the data. This is the structural difference from
every API-shaped tool in this section.</p>
      </section>
      <section title="1.8.5 JSON Schema — NOT FOUND, and the gap is real">
        <list ordered="false" p="286">
          <item>https://json-schema.org/understanding-json-schema/reference/enum — accessed 2026-08-09:
  "The `enum` keyword is used to restrict a value to a fixed set of values. It must be an array
  with at least one element, where each element is unique." The page carries **no** evolution,
  versioning, or compatibility guidance.</item>
          <item>https://json-schema.org/blog/posts/future-of-json-schema — accessed 2026-08-09: the
  compatibility guarantees discussed are for **the specification itself**, not for user schemas
  — "In this case, 'stable' means that there will be strict backward and forward compatibility
  requirements that must be followed for any change." The page does **not** define rules for
  evolving a user's schema compatibly with previously written data.</item>
        </list>
        <p p="287">**NOT FOUND: any normative JSON Schema rule on whether adding or removing an `enum` value is a
compatible change.** Searched: the json-schema.org enum reference, the future-of-JSON-Schema
post, and web searches for JSON Schema evolution/backward-compatibility/versioning official
docs. **JSON Schema has no notion of a writer's schema versus a reader's schema at all** —
there is only validation of an instance against one schema. This is directly and unhappily
relevant to a JSON-files-in-git case: the format ecosystem the questioner is actually in is the
one ecosystem with no published evolution model.</p>
      </section>
      <section title="1.8.6 Apollo GraphOS schema checks — the tool that needs an observable client population">
        <p p="288">URL: https://www.apollographql.com/docs/graphos/platform/schema-management/checks — accessed
2026-08-09.</p>
        <quote p="289">"Operations checks use your graph's historical client operation data to determine whether any
clients would be negatively affected by the proposed schema changes."
"Operations checks run against a maximum of 10,000 distinct operations."</quote>
        <p p="290">**The decisive quote**, from
https://www.apollographql.com/docs/graphos/platform/schema-management/checks/run — accessed
2026-08-09:</p>
        <quote p="291">"If GraphOS has no operation metrics to compare against, all potentially dangerous schema
changes result in a failed check."</quote>
        <p p="292">Prerequisites: "your supergraph is sending operation metrics to GraphOS"; default window is
the last seven days. So Apollo **requires a live cloud service and a live, instrumented client
population**, and without traffic it degrades to fail-closed — it cannot produce a useful
static verdict.</p>
        <p p="293">The breaking/non-breaking list
(https://www.apollographql.com/docs/graphos/platform/schema-management/checks/reference —
accessed 2026-08-09) classifies `VALUE_REMOVED_FROM_ENUM` as breaking —
"A value was removed from an enum used by at least one operation" — and
`VALUE_ADDED_TO_ENUM` as **non-breaking**. Note the qualifier recurring in every breaking rule:
**"used by at least one operation."** Apollo's verdicts are traffic-conditional by
construction; a removed enum value with zero observed usage is not breaking under Apollo.</p>
      </section>
      <section title="1.8.7 oasdiff (OpenAPI) — the only tool that gets enum variance right per position">
        <p p="294">URLs: https://github.com/oasdiff/oasdiff and https://www.oasdiff.com/docs/breaking-changes —
both accessed 2026-08-09.</p>
        <quote p="295">"Command-line tool to compare and detect breaking changes in OpenAPI specs."</quote>
        <quote p="296">oasdiff judges changes "against the API contract your OpenAPI definition declares, not against
what a particular server happens to accept."</quote>
        <p p="297">It ships **separate** checks for enum-value addition and removal on the request side and the
response side — `request-property-enum-value-added`, `request-property-enum-value-removed`,
`response-property-enum-value-added`, `response-property-enum-value-removed`,
`request-parameter-enum-value-added`/`-removed`, `response-mediatype-enum-value-removed`, and
others. That decomposition is variance-correct: **adding to a response enum and adding to a
request enum are opposite risks**, and oasdiff is the only tool surveyed that models them
separately. It also honours `x-extensible-enum`, an explicit "this enum is open" marker, with
dedicated checks (`request-property-x-extensible-enum-value-removed`, etc.). Pure static; no
server, no telemetry.</p>
      </section>
      <section title="1.8.8 Apache Iceberg — schema evolution over immutable files">
        <p p="298">URL: https://raw.githubusercontent.com/apache/iceberg/main/docs/docs/evolution.md — accessed
2026-08-09. Included because it is the other genuinely data-at-rest system found.</p>
        <quote p="299">"Iceberg schema updates are **metadata changes**, so no data files need to be rewritten to
perform the update."</quote>
        <quote p="300">"Iceberg guarantees that **schema evolution changes are independent and free of side-effects**,
without rewriting files:
1. Added columns never read existing values from another column.
2. Dropping a column or field does not change the values in any other column.
3. Updating a column or field does not change values in any other column.
4. Changing the order of columns or fields in a struct does not change the values associated
with a column or field name."</quote>
        <p p="301">Iceberg achieves this with **unique field IDs** — the same trick as Thrift/protobuf field
numbers, applied to columnar storage. Note that **Iceberg's supported-change list contains no
enum concept at all**; there is no enum type in the Iceberg schema model. A system designed
from scratch for evolvable data at rest simply declined to have closed vocabularies.</p>
      </section>
    </section>
    <section title="1.9 LinkedIn — Rest.li. The most explicit doctrine anyone has published (PRIMARY)">
      <p p="302">This is the single most valuable subject in the study. LinkedIn is the only organisation found
that has written a *dedicated essay* on the enum question, and its conclusion is the opposite
of Google's.</p>
      <section title="1.9.1 Q3 — the enum essay, quoted at length">
        <p p="303">URL: https://linkedin.github.io/rest.li/modeling/compatibility_check — accessed 2026-08-09.
The section heading is literally **"Why is Adding to an Enum Considered Backwards
Incompatible?"**</p>
        <quote p="304">"Many developers are surprised that adding to an enum is considered a backwards incompatible
change."</quote>
        <quote p="305">"But, while Rest.li is designed with features to make it easier to add symbols to enums, it
cannot possibly guarantee that adding a enum symbols is backward compatible."</quote>
        <p p="306">The mechanism LinkedIn generates — note that, like Apollo, this only works because LinkedIn
writes the client's codegen:</p>
        <quote p="307">"To make it easier to add values in the enum data schema, java enum classes generated by
Rest.li that correspond to enum data schema always contains a special '$UNKNOWN' symbol.
Whenever Rest.li deserializes enum data that contains a symbol that is not present in the java
enum, Rest.li maps it to '$UNKNOWN'. When the enum is accessed via accessor implemented by a
data template, the accessor will return the new symbol as the java '$UNKNOWN' symbol. This gives
readers of the enum the opportunity to check if the enum is '$UNKNOWN', and if it is, handle is
in the best possible way."</quote>
        <p p="308">**And then the sentence that should be tattooed on this whole research question:**</p>
        <quote p="309">"However, it's still not possible to guarantee backward compatibility, even with the '$UNKNOWN'
symbol available. It's possible that clients did not handle the '$UNKNOWN' symbol in the best
possible way, and even if they did **it may be that they cannot do anything other than fail if
they encounter a enum symbol they do not recognize**. In many practical applications, it is not
feasible to assess how all clients have been coded to handle new enum symbols, particularly when
there are many clients. In such cases, adding a new enum symbol might break a unknown number of
clients."</quote>
        <quote p="310">"It's true that there may be well controlled use case were an enum is used only by a single
client and server that are maintained by the same developers… But if additional clients might be
added in the future, it is still risky to get in the habit of adding enum symbols 'as-if' they
are backward compatible changes."</quote>
        <p p="311">**The prescription, and it is directly applicable to the data-at-rest case:**</p>
        <quote p="312">"Given all these potential issues with adding a enum symbol, it's important to think of adding
enum symbols as backward incompatible. If a new symbols is to be added, a migration strategy for
adding the enum symbol(s) must be performed just as for any other backward incompatible change.
Note that this is only possible when all clients are known and it is possible to coordinate
changes with them. **If this is not the case, one should consider making a backward compatible
change (such as adding a new optional field containing a new enum field with more symbols) and
supporting the existing clients, with the existing enum symbols, indefinitely.**"</quote>
        <p p="313">Read that last clause again. LinkedIn's published advice for the case where **you do not know
your clients and cannot coordinate with them** — which is exactly the questioner's case — is:
**do not grow the enum. Add a new parallel field, and serve the old vocabulary forever.**
That is the only concrete, primary-sourced prescription found anywhere for the uncontrolled-
consumer scenario.</p>
        <p p="314">**And LinkedIn explicitly calls out that this gets worse for data at rest:**</p>
        <quote p="315">"While unknown symbols can be deserialized by older Rest.li consumers (because rest.li does not
require the schema to de-serialize), it doesn't work for data persisted as Avro. Any attempt to
deserialize an avro record containing the new enumeration value with an older schema lacking that
enum will fail."</quote>
        <p p="316">Verified in source (PRIMARY, code) —
https://raw.githubusercontent.com/linkedin/rest.li/master/data/src/main/java/com/linkedin/data/template/DataTemplateUtil.java
— accessed 2026-08-09:</p>
        <fence lang="java" p="317">public static final String UNKNOWN_ENUM = "$UNKNOWN";</fence>
        <p p="318">`stringToEnum` attempts `Enum.valueOf(targetClass, value)`, falls back to
`Enum.valueOf(targetClass, UNKNOWN_ENUM)`, and only then throws `TemplateOutputCastException`.</p>
      </section>
      <section title="1.9.2 The undocumented third compatibility tier — a notable finding">
        <p p="319">The published docs list four levels. The source has **five**. URL:
https://raw.githubusercontent.com/linkedin/rest.li/master/restli-tools/src/main/java/com/linkedin/restli/tools/idlcheck/CompatibilityLevel.java
— accessed 2026-08-09:</p>
        <fence lang="java" p="320">public enum CompatibilityLevel { OFF, IGNORE, WIRE_COMPATIBLE, BACKWARDS, EQUIVALENT; ... }</fence>
        <quote p="321">"The order of the members are critical. Least requirement member comes first."</quote>
        <p p="322">And in `CompatibilityInfo.java` (same tree, accessed 2026-08-09), **`ENUM_VALUE_ADDED` is the
sole member of the `WIRE_COMPATIBLE` level**, described as:</p>
        <quote p="323">"Old readers can deserialize changes serialized by new writers, but may not be able to handle
them correctly."</quote>
        <p p="324">`CompatibilityMessage.java` (accessed 2026-08-09) carries the same judgement in its `Impact`
enum, and note that it is the *only* impact constructed with `false` (non-error):</p>
        <fence lang="java" p="325">  /** New reader is incompatible with old writer. */   BREAKS_NEW_READER(true),
  /** Old reader is incompatible with new writer. */   BREAKS_OLD_READER(true),
  /** New enum value added, which is wire compatible change. However, old readers may not be
      able to handle it. */                            ENUM_VALUE_ADDED(false);</fence>
        <p p="326">**LinkedIn found that "adding an enum value" fit neither *compatible* nor *incompatible*, and
invented a third category containing only that one change.** That is independent
corroboration, from production code, of exactly the tension GitHub named "dangerous" and that
Google and Kubernetes resolve in opposite directions.</p>
        <quote p="327">⚠️ Caveat carried from the research: the source-level ordering and classification are
verified, but the exact pass/fail wiring under each level was not confirmed by executing a
build, and it was not verified that the Gradle plugin accepts `wire_compatible` as a property
value. Treat the *existence and description* of the tier as solid, the *runtime behaviour* as
unconfirmed.</quote>
      </section>
      <section title="1.9.3 Q6 — the compatibility checker, and Q1/Q2/Q5">
        <p p="328">Q6 (https://linkedin.github.io/rest.li/modeling/compatibility_check and
https://linkedin.github.io/rest.li/setup/gradle — both accessed 2026-08-09):</p>
        <quote p="329">"**equivalent** - If the check is run in equivalent mode, no changes to Resources or schemas
will pass. **backwards** - Changes that are considered backwards compatible will pass, otherwise,
changes will fail. **ignore** - The compatibility checker is run, but all changes will pass…
**off** - The compatibility checker will not be run at all."</quote>
        <quote p="330">"By default, the compatibility checker will be run on backwards compatibility mode."</quote>
        <quote p="331">"If you are running a continuous integration environment on a Rest.li project, you will want to
run your compatibility checker on `equivalent`."</quote>
        <p p="332">The honest escape hatch, published in the same page:</p>
        <quote p="333">"You are always free to ignore backwards-incompatible change messages if you know that the
change will not cause problems, or are willing to take steps to ensure that it will not."</quote>
        <p p="334">Q1 — **versionless internally, versioned externally, and LinkedIn says the unversioned public
model failed**
(https://www.linkedin.com/blog/engineering/marketing/under-the-hood-how-we-built-api-versioning-for-linkedin-market
— accessed 2026-08-09):</p>
        <quote p="335">"we were releasing breaking changes almost monthly with different sunset dates – making it hard
for developers to test and plan their roadmap without a predictable release schedule."</quote>
        <quote p="336">"Unversioned APIs also blocked customers from accessing the latest features and caused internal
challenges with new feature development."</quote>
        <p p="337">Q2 — the server is made bilingual and deployed first
(https://linkedin.github.io/rest.li/Rest_li-2_x-upgrade-instructions — accessed 2026-08-09):</p>
        <quote p="338">"Deploy your server. Since Rest.li servers running Rest.li 2.x can understand the 1.x protocol
this is safe to do."</quote>
        <p p="339">Q5 — a published duration for the public API
(https://learn.microsoft.com/en-us/linkedin/marketing/versioning — accessed 2026-08-09):</p>
        <quote p="340">"LinkedIn Marketing API Program publishes new versions monthly, and those versions are supported
for a minimum of one (1) year."</quote>
        <quote p="341">"An error response is returned when the version header is deprecated (e.g., 202401)."</quote>
        <p p="342">**Note the shape, which recurs across every subject:** versionless where the organisation can
*gate* the change (internal, CI-enforced), versioned where it cannot (external partners).</p>
      </section>
    </section>
    <section title="1.10 Netflix — deprecation by observed usage, and eviction as normal (PRIMARY)">
      <p p="343">**Q5 — the most interesting deprecation policy found, because it has no clock at all.**
Tejas Shikhare, 2020-12-11,
https://netflixtechblog.com/how-netflix-scales-its-api-with-graphql-federation-part-2-bbe71aaec44a
— accessed 2026-08-09:</p>
      <quote p="344">"We have a deprecation workflow in place for evolving the schema. We've leveraged GraphQL's
deprecation feature and also track usage stats for every field in the schema. **Once the stats
show that a deprecated field is no longer used, we can make a backward incompatible change to
remove the field from the schema.**"</quote>
      <p p="345">This is the right design when you cannot force clients to upgrade — and it is precisely the
design that **requires an observable client population**. It is unavailable at rest (§4).</p>
      <p p="346">**Q4 — Netflix treats forced obsolescence as routine, and publishes it to users.**
https://help.netflix.com/en/node/112425, /119807 and /295469825389156 — all accessed 2026-08-09:</p>
      <quote p="347">"Unfortunately, Netflix will no longer be available on this device after (DATE)."</quote>
      <quote p="348">"This current app version is no longer supported. Please upgrade the OS and App version.
(R39-1)"</quote>
      <quote p="349">"Please update your device. This version is no longer supported by Netflix. 5072"</quote>
      <p p="350">Support ends when "a device can no longer get necessary updates from its manufacturer or support
new features."</p>
      <p p="351">The device-diversity motivation, Daniel Jacobson, 2012-07-09,
https://netflixtechblog.com/embracing-the-differences-inside-the-netflix-api-redesign-15fd8b3dc49d
— accessed 2026-08-09:</p>
      <quote p="352">"Netflix's streaming service is available on more than 800 different device types, almost all of
which receive their content from our private APIs."</quote>
      <quote p="353">"supporting these myriad device types with an OSFA API, while successful, is not optimal for the
API team, the UI teams or Netflix streaming customers."</quote>
      <quote p="354">"its emphasis is to make it convenient for the API provider, not the API consumer."</quote>
      <p p="355">Protobuf-side instinct, same conclusion as everyone else
(https://netflixtechblog.com/practical-api-design-at-netflix-part-1-using-protobuf-fieldmask-35cfdc606518
— accessed 2026-08-09):</p>
      <quote p="356">"Never rename fields when FieldMask is used. This is the simplest solution, but it's not always
possible"
"Deprecate old and create a new field instead of renaming."</quote>
      <p p="357">**Q3 — NOT FOUND.** No Netflix engineering post or DGS documentation addresses enum growth.
What *is* verifiable is a tolerant default in the DGS client's Jackson configuration
(https://raw.githubusercontent.com/Netflix/dgs-framework/master/graphql-dgs-client/src/main/kotlin/com/netflix/graphql/dgs/client/GraphQLResponse.kt
— accessed 2026-08-09): `enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)`.
That flag requires an enum constant annotated `@JsonEnumDefaultValue`, and **no evidence was
found that dgs-codegen generates one** — checked https://netflix.github.io/dgs/generating-code-from-schema/
(no enum-growth or backward-compatibility guidance). So the tolerance is configured but
possibly unarmed. Searched additionally: dgs-codegen issue #19 (about schema default rendering,
not unknown values) and dgs-framework discussion #1042 (maintainer acknowledges the mapper
should be configurable; says nothing about enum growth).</p>
      <p p="358">**Q6 — NOT FOUND as a hard refusal.** Federation Part 2 describes a schema registry, CI/CD
integration and a schema working group, but does **not** describe rejecting breaking changes.</p>
      <p p="359">**NOT FOUND:** an explicit Netflix *engineering* statement of the form "we cannot update these
devices". Searched netflixtechblog.com for "cannot be updated" / "never be updated" / "unable
to update", device-certification posts, the TV UI deployment post, the Android TV scaling post,
Federation Parts 1–2, and the Android backend-swap post. The *consumer-facing* help pages above
are the primary evidence, and they show eviction rather than indefinite support.</p>
    </section>
    <section title="1.11 Uber — the clearest mobile-specific CI gate found (PRIMARY)">
      <p p="360">**Q6 — the single best quote in the study on gating mobile schema changes.**
https://www.uber.com/blog/architecture-api-gateway/ — accessed 2026-08-09 (corroborated
identical at the /en-IN/ path):</p>
      <quote p="361">"All of Uber's mobile apps generate services and models based on the Thrift IDL to interact with
the server. A CI job fetches all of the endpoint IDL from the gateway and runs a custom
code-generation for the various models… **Any backward-incompatible change to an endpoint schema
is prevented by a CI job that runs against the generated code review.**"</quote>
      <p p="362">**Q1 — versionless, stated unusually bluntly.**
https://raw.githubusercontent.com/uber/idl/master/README.md — accessed 2026-08-09:</p>
      <quote p="363">"There should only be one version of the world. Your company runs at a single version of each
service in production… treated as a single versioned collection."
"You cannot pick and choose which services to update. This is intentional."</quote>
      <p p="364">And on the protobuf side
(https://raw.githubusercontent.com/uber/prototool/dev/style/README.md — accessed 2026-08-09):</p>
      <quote p="365">"**Your API as a whole should not need semantic versioning - one of the core promises of Protobuf
is forwards and backwards compatibility, and this should extend to your code as well.**"</quote>
      <quote p="366">"Breaking changes should never be made in stable packages… Both wire-incompatible and
source-code-incompatible changes are considered breaking changes."</quote>
      <p p="367">**Q3 — Uber's two transports disagree with each other, and the mobile-facing one is the strict
one.** Binary Thrift accepts anything
(https://raw.githubusercontent.com/thriftrw/thriftrw-go/dev/gen/enum.go — accessed 2026-08-09):
`FromWire` simply casts `w.GetI32()` with no validation, and carries the comment
`// TODO(abg) define an error type in the library for unrecognized enums.`</p>
      <p p="368">JSON-by-name does not
(https://raw.githubusercontent.com/uber/zanzibar/master/docs/thrift.md — accessed 2026-08-09):</p>
      <quote p="369">"A thrift `enum` is a JSON string. The string value must be one of the enum names defined in the
thrift `enum` declaration."</quote>
      <p p="370">with `UnmarshalText` returning `fmt.Errorf("unknown enum value %q for %q: %v", ...)`.</p>
      <p p="371">Uber separately states "All mobile to server communications were primarily in HTTP/JSON"
(https://www.uber.com/blog/gatewayuberapi/ — accessed 2026-08-09). **Uber never draws the
conclusion that this makes enum growth unsafe for installed apps; that inference is mine, and
is flagged as inference, not quotation.**</p>
      <p p="372">Uber's `INVALID = 0` is about *unset*, not *unknown* — same as Google's `_UNSPECIFIED`:</p>
      <quote p="373">"All enum values must have a 0 `INVALID` value."
"The invalid value carries no semantic meaning… if a value can be purposefully unset… there
should be a `UNSET` value as the 1 value."</quote>
      <p p="374">**Q4 — NOT FOUND (primary).** No Uber engineering post on forced upgrades or minimum app
version. Uber's published strategy is the opposite: "By shipping the older version of the app
along with the major rewrite, we can adjust the variables for the rollout or fall back to an app
that has a proven record of stability."
(https://www.uber.com/blog/carbon-dual-binary-mobile-app/ — accessed 2026-08-09.)</p>
      <p p="375">**SECONDARY (weak, and the page itself says answers were "edited down and summarized")** —
Gergely Orosz, former Uber engineering manager,
https://bitrise.io/blog/post/q-and-a-on-building-apps-at-scale-part-1 — accessed 2026-08-09:</p>
      <quote p="376">"the problem we had at Uber — we had force updates in place, we never tested it too much, but we
never used it. Because every time when we were about to use it, the business looked at it and
said like, 'oh, there's still one and a half percent of our users using the older Uber app'… and
this means, like, $100 million per year in revenue."</quote>
      <p p="377">If accurate, this is the most honest published account of why force-upgrade is a theoretical
tool rather than a practical one at consumer scale. Labelled SECONDARY; do not rely on it alone.</p>
      <p p="378">**Q5 — NOT FOUND.** The mechanism is published ("Instead of making a breaking change, rely on
deprecation of types"; "Do not use the `reserved` keyword in messages or enums. Instead, rely on
the `deprecated` option") but no duration is. The post titled "API **Lifecycle** Management
Platform" was fetched twice targeting lifecycle/deprecation/sunset/retire and returned none of
those terms.</p>
      <p p="379">⚠️ `uber/prototool` is **archived / read-only as of 2026-03-04**.</p>
    </section>
    <section title="1.12 Airbnb — server-driven UI, and a conspicuous silence (PRIMARY)">
      <p p="380">**Q2 — the most explicit normative statement that the burden is on the SERVER.**
Ryan Brooks, 2021-06-29,
https://medium.com/airbnb-engineering/a-deep-dive-into-airbnbs-server-driven-ui-system-842244c5f5
— accessed 2026-08-09:</p>
      <quote p="381">"That's essentially what SDUI does — we pass both the UI and the data together, and **the client
displays it agnostic of the data it contains**."</quote>
      <quote p="382">"Everything from the screen's layout, how sections are arranged in that layout, the data
displayed in each section, and even the actions taken when users interact with sections is
controlled by a single backend response across our web, iOS, and Android apps."</quote>
      <quote p="383">"GP provides many 'core' section components… meant to be configurable, styleable, and **backward
compatible from the backend** so we can adapt to any feature's use case."</quote>
      <p p="384">**But the tolerance is conditional, and Airbnb says so.** The conditional clause is load-bearing
and is truncated by most search engines. Ryan Brooks (`rbro112`), Airbnb,
https://github.com/MobileNativeFoundation/discussions/discussions/47 — accessed 2026-08-09:</p>
      <quote p="385">"Mobile has always had a versioning problem, as we well know users don't always update their
apps. Depending on the SDUI system, it's common to be able to launch features back to previous
releases with no client code changes needed **assuming the response is supported**."</quote>
      <p p="386">And they concede the safety net leaks:</p>
      <quote p="387">"Given the backend can change a response instantly (and dynamically for different content),
ensuring clients can support these ever-changing responses is challenging. We leverage screenshot
testing, E2E testing &amp; robust mocking, but **that still doesn't catch everything**."</quote>
      <p p="388">**Q1 — one shared schema; Airbnb never uses the word "versionless":**</p>
      <quote p="389">"The key decision that helped us make our server-driven UI system scalable was to use a single,
shared GraphQL schema for Web, iOS, and Android apps — i.e., we're using the same schema for
handling responses and generating strongly typed data models across all of our platforms."</quote>
      <p p="390">**Q3 — NOT FOUND, and it is the most conspicuous gap in the entire study.** Verified by
exact-phrase check against the full raw article text: the strings **"unknown"** and
**"deprecat"** are **absent from the SDUI deep-dive entirely** — despite the architecture
resting on two open-ended growth axes that the same article documents:</p>
      <quote p="391">"In GraphQL schema, GP sections are a **union of all possible section types**."
"One important concept to touch on is `SectionComponentType`." / "`SectionComponentType` controls
_how_ a section's data model is rendered."</quote>
      <p p="392">The nearest thing to a mechanism is a runtime registry with **no stated miss behaviour**:</p>
      <quote p="393">"We leverage a plugin system on a per-feature basis to pull in section renderers at runtime. On
Android specifically, we accomplish this through Dagger multibindings."</quote>
      <p p="394">⚠️ **Integrity note:** several third-party blogs claim Airbnb "returns a fallback component" or
"never crashes on unknown components." **No Airbnb source for this was found. Do not attribute
it to Airbnb.** The research also caught search-engine-fabricated quotes attributed to the
Airbnb blog (a "versioning problem" sentence that actually appears, with different wording, in
a GitHub comment); those were discarded after exact-phrase verification against raw article text.</p>
      <p p="395">**Q4 — NOT FOUND.** No Airbnb statement on force-upgrade or minimum-version gating. Their
framing is that needing a release is the *defect SDUI removes*, and the stated pain is
**measurement**, not crashes:</p>
      <quote p="396">"Finally, mobile has a versioning problem. Each time we need to add new features to our listing
page, we need to release a new version of our mobile apps for users to get the latest experience.
**Until users update, we have few ways to determine if users are using or responding well to these
new features.**"</quote>
      <p p="397">**Q5 — NOT FOUND for mobile.** The Thrift-side rule has no clock: "Schema field deprecation
should only happen after we are sure the fields are no longer used."
(https://medium.com/airbnb-engineering/building-services-at-airbnb-part-4-23c95e428064 —
accessed 2026-08-09.) The only published duration points the *other* way — at the partner,
not the publisher (https://www.airbnb.com/help/article/3418 — accessed 2026-08-09):</p>
      <quote p="398">"(v) implementing all mandatory API features within 6 months of their release."</quote>
      <p p="399">**Q6 — YES for Thrift, NOT for the mobile GraphQL/SDUI contract** (same Part-4 post, accessed
2026-08-09):</p>
      <quote p="400">"Static API Schema Validation is a tool we build to automatically detect bad API schema changes"
"Now with static schema validation, we are able to **detect bad API changes and prevent them
before code merge** (e.g., field type change, field id change)."</quote>
      <p p="401">with a motivating incident stated outright:</p>
      <quote p="402">"Adding a new data field broke the listing availability service's API xxx before the
corresponding service code changes got deployed."</quote>
      <p p="403">**BLOCKED, not NOT-FOUND:** Airbnb's partner-API versioning policy at
https://developer.withairbnb.com/docs/homes/versioning 302-redirects to a login wall
(`oauth.readme.io`) — accessed 2026-08-09. It is the highest-value unresolved target in the
study.</p>
    </section>
    <section title="1.13 Twitter / X (PRIMARY)">
      <p p="404">**Fetch note:** `blog.x.com` and `blog.twitter.com` return **HTTP 403** to every fetcher
(Cloudflare JS challenge); `developer.x.com` returns **HTTP 402 Payment Required**. All Twitter
primaries below were recovered from Wayback raw (`…id_/`) snapshots via curl. Quotes marked
byte-verified were extracted from raw bytes with tags stripped locally.</p>
      <section title="1.13.1 Q1 / Q5 — Twitter versions explicitly, and publishes the clock">
        <p p="405">URL: `https://web.archive.org/web/20200924190056id_/https://developer.twitter.com/en/docs/twitter-api/versioning`
— accessed 2026-08-09 (byte-verified).</p>
        <quote p="406">"We are introducing a versioning strategy in our efforts to build a stable and reliable Twitter
API. Developers can know when to expect changes to Twitter's public APIs and be given time to
migrate to new versions."</quote>
        <quote p="407">"Versioning for the Twitter API will be represented by version numbers declared in the route path
for our endpoints: `https://api.twitter.com/2/tweets`"</quote>
        <quote p="408">"We aim to release major versions of the public API as necessary no more than every 12 months. A
major version will be released when breaking (outlined below) changes are introduced in the API…
Non-breaking changes will be additive and rolled out to the most recent version when ready,
requiring no work on a developer's end"</quote>
        <p p="409">**Q5 — the published durations:**</p>
        <quote p="410">"As soon as a new version is released, the previous version will be marked as deprecated.
Versions will remain in a deprecated state for one year, after which they will be retired. In
effect, any version will be available for at least two years overall, including their deprecation
period. Any calls made to versions after they are retired will fail."</quote>
        <p p="411">The **breaking** list, verbatim: "Addition of a new required parameter / Removal of an existing
endpoint / Removal of any field in the response (either required or optional) / Removal of a
query parameter / Restructuring of the input or output format… / Changing the name or data type
of an existing input parameter or output value / Changing the name of a field / Changing the
resource name / Changing a response code / Changing error types / Changes to existing
authorization scopes".</p>
        <p p="412">The **non-breaking** list, verbatim: "Addition of a new endpoint / Addition of a new optional
parameter / Addition of a new response field / Reordering of fields / Changing text in error
messages / Availability of new scopes / 'Nulling' of fields".</p>
        <p p="413">**Q3 — NOT FOUND, and the absence is itself the finding.** *Enum or field-value growth appears
in neither list.* Twitter enumerates eleven breaking changes and seven non-breaking ones, and
never rules on adding a value to a closed vocabulary. Two entries in the non-breaking list —
"Addition of a new response field" and "Reordering of fields" — silently require client
tolerance that is never stated as an obligation.</p>
      </section>
      <section title="1.13.2 Q4 — the v1 → v1.1 forced migration, with dates">
        <p p="414">All byte-verified from Wayback, accessed 2026-08-09.</p>
        <p p="415">"Changes coming in Version 1.1 of the Twitter API", 2012-08-16
(`https://web.archive.org/web/20240416135831id_/https://blog.twitter.com/developer/en_us/a/2012/changes-coming-to-twitter-api`):</p>
        <quote p="416">"When we release version 1.1 of the API we will simultaneously announce the deprecation of v1.0.
From the day of the release, developers will have six months to migrate applications from v1.0
to v1.1."</quote>
        <quote p="417">"In version 1.1, we will require every request to the API to be authenticated."</quote>
        <p p="418">The clauses that killed third-party clients:</p>
        <quote p="419">"If your application displays Tweets to users, and it doesn't adhere to our Display Requirements,
we reserve the right to revoke your application key."</quote>
        <quote p="420">"If your application already has more than 100,000 individual user tokens, you'll be able to
maintain and add new users to your application until you reach 200% of your current user token
count… Once you reach 200% of your current user token count, you'll be able to maintain your
application to serve your users, but you will not be able to add additional users without our
permission."</quote>
        <p p="421">"API v1 Retirement: Final Dates", 2013-03-29
(`https://web.archive.org/web/20240226063445id_/…/api-v1-retirement-final-dates`):</p>
        <quote p="422">"The Twitter REST API v1 will officially retire on Tuesday, May 7, 2013."</quote>
        <quote p="423">"We will hold another blackout test on April 16, 2013 beginning at 23:00 UTC"</quote>
        <quote p="424">"Authenticated &amp; unauthenticated requests to api.twitter.com/1/* will receive HTTP 410 Gone. Use
API v1.1 instead."</quote>
        <p p="425">"API v1 Retirement is Complete", 2013-06-11
(`https://web.archive.org/web/20231211182010id_/…/api-v1-is-retired`):</p>
        <quote p="426">"Today, we are retiring API v1 and fully transitioning to API v1.1."
"Based on the blackout tests and looking at the numbers, we can see that the vast majority of
applications have transitioned to API v1.1."</quote>
        <p p="427">**Timeline:** announced 2012-08-16 → released 2012-09-05 → 6-month window → blackout tests
(rehearsals for the kill) → target 2013-05-07 → actual completion 2013-06-11. Announced window
6 months, elapsed ~9 months, terminating in `HTTP 410 Gone`. **A forced migration, executed,
with public rehearsals.**</p>
      </section>
      <section title="1.13.3 Q1 — the fields/expansions rationale: a NEGATIVE finding">
        <p p="428">Twitter built the same sparse-fieldset mechanism GraphQL uses, but **published no
schema-evolution rationale for it.** The most that exists
(`https://web.archive.org/web/20240303153815id_/https://developer.twitter.com/en/docs/twitter-api/data-dictionary/using-fields-and-expansions`
— accessed 2026-08-09, byte-verified):</p>
        <quote p="429">"This simplicity, along with the fields and expansions parameters, enable you to request only
those fields you require, depending on your use case."</quote>
        <p p="430">**NOT FOUND: any Twitter statement connecting fields/expansions to schema evolution, forward
compatibility, or client tolerance.** This is the striking contrast of the whole study —
**Twitter built GraphQL's mechanism and drew none of GraphQL's conclusions, shipping an explicit
`/2/` version with a one-year deprecation clock alongside it.** Badoo built the same mechanism
and *did* draw the conclusion ("There is no v2", §1.14).</p>
        <p p="431">**NOT RETRIEVABLE:** "Introducing a new and improved Twitter API" (July 2020), the actual v2
announcement. Wayback holds only 301 redirects for every URL variant on both `blog.twitter.com`
and `blog.x.com`; live is 403. No verbatim text was obtained and none is paraphrased here.</p>
      </section>
      <section title="1.13.4 Q3 for Twitter&apos;s *internal* stack — Scrooge, and the default throws">
        <p p="432">URL: `https://raw.githubusercontent.com/twitter/scrooge/develop/scrooge-core/src/main/scala/com/twitter/scrooge/ThriftEnum.scala`
— accessed 2026-08-09 (byte-verified). Doc comments from Twitter's own Thrift codegen:</p>
        <quote p="433">"Base class for unknown enum items. The implementations are used for backward compatibility
during enum update at producer."</quote>
        <quote p="434">"Find the enum by its integer value, as defined in the Thrift IDL. **Throws NoSuchElementException
exception if the value is not found**" — `def apply(value: Int): T`</quote>
        <quote p="435">"Find the enum by its integer value… **If the value is not found it returns a special enum unknown
value** of type T that extends EnumItemUnknown type. In particular this allows ignoring new values
added to an enum in the IDL on the producer side when the consumer was not updated."
— `def getOrUnknown(value: Int): T`</quote>
        <p p="436">`CHANGELOG.rst` (byte-verified, v3.18.0): "scrooge: Support ignoring unknown enum ids."</p>
        <p p="437">**The tolerance is opt-in and the default throws.** That is the same posture found in Jackson,
kotlinx.serialization, Moshi and Swift (§3.2a) — and Twitter never surfaced any of it to public
API consumers.</p>
        <p p="438">**NOT FOUND:** any Twitter blog post or design doc on Thrift/Scrooge schema evolution. The
`twitter.github.io/scrooge/Semantics.html` page covers required/optional and default values and
contains no text on schema evolution, compatibility, passthrough fields, or enum handling.</p>
      </section>
    </section>
    <section title="1.14 Badoo / Bumble — NOT &quot;NOT FOUND&quot;. Richly documented, and directly on point (PRIMARY)">
      <p p="439">The brief anticipated a null result here. It is wrong: Badoo has published more concrete
operational detail on this exact problem than most of the larger companies. The decisive move
was fetching the **authored markdown** from `github.com/badoo/techblog` (the blog's source
repository) rather than the Medium-redirected rendering.</p>
      <section title="1.14.1 Q1 — Badoo explicitly rejects protocol versioning, and says why">
        <p p="440">Source: Ivan Biryukov (Mobile Architect) &amp; Orene Gauthier (Head of Mobile Engineering),
"Crazy Agile API", dated 2016-05-11.
URL: `https://raw.githubusercontent.com/badoo/techblog/master/_posts/2016-05-04-crazy-agile-api.markdown`
— accessed 2026-08-09 (byte-verified). Rendered at
`https://medium.com/bumble-tech/crazy-agile-api-5130be6f5b06`.</p>
        <p p="441">The protocol and its scale:</p>
        <quote p="442">"Our Badoo API is a set of data structures (messages) and values (enum values) that the client
and the server send to each other. It is written in Google protobuf definitions and stored in a
separate git repository."</quote>
        <quote p="443">"- 450 messages, 2665 fields
- 135 enums, 2096 values
- 125 features flags that can be controlled from server
- 165 functionality flags. We call them supported features"</quote>
        <p p="444">The rejection, verbatim:</p>
        <quote p="445">"**Protocol level** — This approach is widely used for slow-changing public APIs. When new version
of protocol are released, all the clients are suppose to start using it instead of the old one. We
can't use it as different client platforms have different sets of features implemented… So if the
client needs to implement feature D, it will also have to upgrade feature B to B', which might be
not needed at the moment. **At Badoo we never used this versioning approach.**"</quote>
        <quote p="446">"Our protocol is shared between our server and our 5 client platforms. As our clients release a
new version each week (resulting in ~20 app versions per month, all of which can behave
differently and use different parts of the protocol), we can't just create a different protocol
version for every app release. Such protocol versioning will require server to support thousands
of various combinations of apps behaviours, which is far not ideal."</quote>
      </section>
      <section title="1.14.2 Q2 — the client declares, the server adapts. Stated normatively.">
        <quote p="447">"A better option—the one we decided implement— would be for each client to declare at the start
which versions of the protocol bits they support. This allows the server to be client agnostic
when it comes to feature support and just rely on the list of supported features provided by the
client."</quote>
        <quote p="448">"Clients that support it send the server a SUPPORTS_WHATS_NEW flag. The server then knows that it
can send What's New messages to the client and that they be displayed correctly."</quote>
        <quote p="449">"On a side note we always use the optional fields. This gives us the flexibility to deprecate
fields."</quote>
      </section>
      <section title="1.14.3 Q3 — Badoo&apos;s answer to the enum question is PREVENTION, and they diagrammed it">
        <p p="450">Source: Konstantin Yakushev, "Versioning strategy for a complex internal API", Nordic APIs
Platform Summit, deck published 2016-11-11.
URL: `https://www.slideshare.net/BadooDev/versioning-strategy-for-a-complex-internal-api`
— accessed 2026-08-09 (slide transcript extracted from raw HTML; byte-verified).</p>
        <p p="451">**This deck is the single most on-point artifact in the entire study — it literally draws the
unknown-enum failure.** Scale and the version tail carried:</p>
        <quote p="452">"API / Evolving since 2010 / RPC-style / non-restful protobuf-based / 570 commands / 1200 classes
/ 9 releases each week"</quote>
        <quote p="453">"Badoo versions — ~ 5 last versions / last iOS 7 version / ~ 10 last versions / last Android 2.x
version / ~ 2 last versions / last WP7 version"</quote>
        <p p="454">The thesis slides:</p>
        <quote p="455">"Typical versioning — 1) http://api.example.com/orders 2) Collect nice-to-have breaking changes
3) Announce new version with all of them 4) http://api.example.com/v2/orders 5) Slowly deprecate v1"
"The least important step" *(annotation pointing at step 4, the URL)*
"There is no v2"
"Continuous versioning"</quote>
        <p p="456">**The enum/type-growth failure, drawn as slides 29–33:**</p>
        <quote p="457">"Badoo has 34 types of banners"</quote>
        <p p="458">Slide 29 — the failure state:
&gt; "user_list_request: { fields: [banners] }
&gt; user_list: { banners: [**&amp;lt;unknown banner&amp;gt;**] }"</p>
        <p p="459">Slide 30 — the rejected fix (versioning the field name):
&gt; "user_list_request: { fields: [banners_v24] }"</p>
        <p p="460">Slide 31 — the adopted fix (client enumerates what it knows):
&gt; "user_list_request: { fields: [banners], supported_banners: [EXTRA_SHOWS] }
&gt; user_list: { banners: [&amp;lt;extra shows banner&amp;gt;] }"</p>
        <p p="461">Slide 33 — the rule, stated normatively:
&gt; "Problem: similar structures of different types
&gt; **Release a new thing on server whenever. Make clients send supported types explicitly.**"</p>
        <p p="462">And for behavioural change:
&gt; "Problem: business logic changes
&gt; **Do changes behind version flag. Make client control those flags.**"</p>
        <quote p="463">"Problem: simultaneous release on clients
**Negotiate feature with server. Once you see that enough clients support it, launch.**"</quote>
        <p p="464">The five-rule summary:
&gt; "Continuous versioning — 0. Add new fields for new features 1. Have a list of supported things
&gt; 2. Cover changes with a change flag 3. Let server control enabling and disabling 4. Create
&gt; supersets of APIs for experimenting"</p>
        <p p="465">Results: "On practice — 257 feature flags / 161 negotiable features". Closing vanity URL:
`http://no-v2.kojo.ru`.</p>
        <p p="466">**Note precisely what Badoo's answer is: the client sends an inventory of the vocabulary it
understands, and the server promises never to send anything outside it.** This is not tolerance;
it is *negotiation*. It is the most robust answer found anywhere — and it is 100% dependent on
there being a request in which the client can state its inventory. It is the practice that
transfers *least* to a file (§4).</p>
      </section>
      <section title="1.14.4 Q6 — the adoption dashboard, and Q4 — both upgrade modes">
        <p p="467">Slide 61, verbatim:</p>
        <quote p="468">"Dashboard
4.44 4.43 4.42 4.41 3.57
VIDEOS_IN_PHOTOS + +
BUTTONS_ARRAY + + + + +
ALL_DATES_ARE_UTC"</quote>
        <p p="469">A per-app-version × per-change-flag adoption matrix. `ALL_DATES_ARE_UTC` has no `+` in any
column — a flag not yet adopted anywhere. This is how Badoo decides when a flag can be retired.
**It is pure A2 (observability); it does not exist at rest.**</p>
        <p p="470">Q4 — consecutive slides read simply:
&gt; "Suggest upgrading"
&gt; "Force upgrading"</p>
        <p p="471">Badoo has both, presented as a normal matched pair with no apology.</p>
      </section>
      <section title="1.14.5 Q5 — no published duration, and Badoo explains why">
        <p p="472">From "Crazy Agile API" (byte-verified):</p>
        <quote p="473">"For public API the deadline is usually set and an old part stops working on this date. At Badoo
this is not always possible as tasks to implement new features often have a much higher priority
then removing old features. Thus we have a 3 stage process for that."</quote>
        <quote p="474">"During the second stage, all the clients should remove deprecated protocol usage from their
code. At this point server can't remove code as some older versions of apps can still be in
production."</quote>
        <quote p="475">"During the last stage when all clients have removed their code and no production versions left
that use the protocol, it can then be removed from server code and protocol itself."</quote>
        <p p="476">The Russian original on Habr (`https://habr.com/ru/company/badoo/blog/305888/` — accessed
2026-08-09) contains a clause absent from the English text, making the premise explicit:</p>
        <quote p="477">"на сервере нельзя удалять этот код ещё достаточно долго — **не все пользователи обновляют свои
приложения быстро**"
("on the server this code cannot be removed for a long time yet — not all users update their apps
quickly")</quote>
        <p p="478">**NOT FOUND for Badoo:** (a) any statement of what a client does when prevention fails and it
*does* receive an unknown enum value — the deck's answer is prevention, never recovery;
(b) any published deprecation duration (explicitly rejected as impossible); (c) any automated
CI schema-compatibility gate — the gates are human protocol review and the adoption dashboard.
Queries run are listed in §5.</p>
        <p p="479">⚠️ **SECONDARY, do not cite as Badoo's words:** `https://nordicapis.com/continuous-versioning-strategy-for-internal-apis/`
(Bill Doerrfeld, 2017-03-14) summarises the same talk and asserts Badoo "never had a breaking
change… since 2010." That phrase could not be byte-verified against anything Yakushev said.</p>
      </section>
    </section>
    <section title="1.15 Published post-mortems where a schema/config change broke deployed consumers">
      <p p="480">This was the hardest target and it produced the most directly transferable evidence in the
report — because the best cases are not API-versioning incidents at all. **They are cases where
a data file changed shape and already-deployed readers could not cope.** That is exactly the
questioner's failure mode.</p>
      <section title="1.15.1 ★ Cloudflare, 2025-11-18 — the closest published analogue to the data-at-rest case">
        <p p="481">URL: https://blog.cloudflare.com/18-november-2025-outage/ — accessed 2026-08-09 (byte-verified).
Author: Matthew Prince.</p>
        <p p="482">The causal chain is: **a schema/permissions change → a generated data file gains extra rows →
the file exceeds a hardcoded limit compiled into already-deployed consumers → global crash.**</p>
        <quote p="483">"it was triggered by a change to one of our database systems' permissions which caused the
database to output multiple entries into a 'feature file' used by our Bot Management system. That
feature file, in turn, doubled in size. The larger-than-expected feature file was then propagated
to all the machines that make up our network."</quote>
        <quote p="484">"The software had a limit on the size of the feature file that was below its doubled size. That
caused the software to fail."</quote>
        <p p="485">The schema change looked entirely benign:</p>
        <quote p="486">"Since users already have implicit access to underlying tables in r0, we made a change at 11:05 to
make this access explicit, so that users can see the metadata of these tables as well."</quote>
        <quote p="487">"Unfortunately, there were assumptions made in the past, that the list of columns returned by a
query like this would only include the 'default' database"</quote>
        <p p="488">The limit that had never been approached:</p>
        <quote p="489">"the Bot Management system has a limit on the number of machine learning features that can be used
at runtime. Currently that limit is set to 200, well above our current use of ~60 features."
"When the bad file with more than 200 features was propagated to our servers, this limit was hit —
resulting in the system panicking."
"thread fl2_worker_thread panicked: called Result::unwrap() on an Err value"</quote>
        <p p="490">**Version skew changed the failure MODE, not merely its presence** — this is the single most
instructive sentence for anyone reasoning about mixed-vintage readers:</p>
        <quote p="491">"Customers deployed on the new FL2 proxy engine, observed HTTP 5xx errors. Customers on our old
proxy engine, known as FL, did not see errors, but bot scores were not generated correctly,
resulting in all traffic receiving a bot score of zero. Customers that had rules deployed to block
bots would have seen large numbers of false positives."</quote>
        <p p="492">The old reader "kept working" in the worst possible way: silently, wrongly, and confidently.</p>
        <p p="493">**A partially rolled-out schema change produced flapping, not clean failure:**</p>
        <quote p="494">"Bad data was only generated if the query ran on a part of the cluster which had been updated. As
a result, every five minutes there was a chance of either a good or a bad set of configuration
files being generated and rapidly propagated across the network."</quote>
        <p p="495">The remediation Cloudflare committed to is the whole lesson in one line:</p>
        <quote p="496">"Hardening ingestion of Cloudflare-generated configuration files in the same way we would for
user-generated input"</quote>
        <quote p="497">"Today was Cloudflare's worst outage since 2019."</quote>
      </section>
      <section title="1.15.2 ★ Google Cloud, 2025-06-12 — blank fields in replicated data crash deployed binaries">
        <p p="498">URL: https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW — accessed 2026-08-09
(byte-verified).</p>
        <quote p="499">"On May 29, 2025, a new feature was added to Service Control for additional quota policy checks.
This code change and binary release went through our region by region rollout, but the code path
that failed was never exercised during this rollout due to needing a policy change that would
trigger the code… Without the appropriate error handling, the null pointer caused the binary to
crash."</quote>
        <quote p="500">"This policy data contained unintended blank fields… This pulled in blank fields for this
respective policy change and exercised the code path that hit the null pointer causing the
binaries to go into a crash loop. This occurred globally given each regional deployment."</quote>
        <p p="501">**Latent code plus activating data, separated by two weeks.** The commitments made afterwards
are directly reusable as design rules:</p>
        <quote p="502">"We will modularize Service Control's architecture, so the functionality is isolated and **fails
open**. Thus, if a corresponding check fails, Service Control can still serve API requests."</quote>
        <quote p="503">"We will audit all systems that consume globally replicated data. Regardless of the business need
for near instantaneous consistency of the data globally…, data replication needs to be propagated
incrementally with sufficient time to validate and detect issues."</quote>
      </section>
      <section title="1.15.3 Cloudflare, 2019-07-02 — why config changes bypass the safety rails code enjoys">
        <p p="504">URL: https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/ — accessed
2026-08-09 (byte-verified). The most quotable passage on the structural double standard:</p>
        <quote p="505">"The SOP for a rule change specifically allows it to be pushed globally. This is very different
from all the software we release at Cloudflare where the SOP first pushes software to an internal
dogfooding network point of presence (PoP)…, then to a small number of customers in an isolated
location, followed by a push to numerous customers and finally to the world."</quote>
        <quote p="506">"However, in this case, that speed meant that a change to the rules went global in seconds."</quote>
        <quote p="507">"The SOP allowed a non-emergency rule change to go globally into production without a staged
rollout."</quote>
      </section>
      <section title="1.15.4 Fastly, 2021-06-08 — a *valid* config triggers a latent bug">
        <p p="508">URL: https://www.fastly.com/blog/summary-of-june-8-outage — accessed 2026-08-09 (byte-verified).</p>
        <quote p="509">"We experienced a global outage due to an undiscovered software bug that surfaced on June 8 when
it was triggered by a **valid** customer configuration change."
"On May 12, we began a software deployment that introduced a bug that could be triggered by a
specific customer configuration under specific circumstances."
"Early June 8, a customer pushed a **valid** configuration change that included the specific
circumstances that triggered the bug, which caused 85% of our network to return errors."</quote>
        <p p="510">The word "valid" carries the lesson: schema-legal and still fatal. Passing your own validator is
not evidence that deployed readers will cope.</p>
      </section>
      <section title="1.15.5 Slack, 2021-02-24 — &quot;how we broke your Slack app&quot;">
        <p p="511">URL: https://web.archive.org/web/20230129150309/https://api.slack.com/changelog/2021-02-24-how-we-broke-your-slack-app
— accessed 2026-08-09 (byte-verified; live page is an SPA shell).</p>
        <quote p="512">"Hello! You are here because three monumental things changed on the Slack platform today,
February 24, 2021."</quote>
        <quote p="513">"These deprecation and retirements are rolling out gradually on February 24, 2021. **Your apps or
integrations may work fine in one workspace but break in another.**"</quote>
        <quote p="514">"We retired every Web API method in the channels.*, im.*, mpim.*, and groups.* namespaces.
Requests to these methods now return a `method_deprecated` error."</quote>
      </section>
      <section title="1.15.6 Concrete enum-growth incidents (PRIMARY, issue trackers)">
        <p p="515">**Google broke its own official client library by adding an enum value.** URL:
https://github.com/protocolbuffers/protobuf/issues/16857 — accessed 2026-08-09 (byte-verified):</p>
        <quote p="516">"After releasing a new version of the Google Ads API (v16_1)… we added one more new enum value to
`CriterionType`. But when a user uses that enum class to parse a new value (`LIFE_EVENT`), it
failed with `Enum Google\Ads\GoogleAds\V16\Enums\CriterionTypeEnum\CriterionType has no name
defined for value 41`"</quote>
        <quote p="517">"**What did you expect to see** — Based on this doc, it should just parses the value without any
issues. I'd expect it to represent a new value with `UNKNOWN` and all methods in that enum class
should just work."</quote>
        <p p="518">**Real end-user crashes from a server-delivered enum, with stack trace.** URL:
https://github.com/facebook/facebook-android-sdk/pull/544 (merged 2019-02-06) — accessed
2026-08-09 (byte-verified):</p>
        <quote p="519">"Your current SDK crashes all the time for real users:"</quote>
        <fence p="520">java.lang.IllegalArgumentException:
   at java.lang.Enum.valueOf (Enum.java:257)
   at com.facebook.appevents.codeless.internal.EventBinding$MappingMethod.valueOf (EventBinding.java:154)
   at com.facebook.appevents.codeless.internal.EventBinding.getInstanceFromJson (EventBinding.java:82)
   at com.facebook.appevents.codeless.CodelessMatcher$ViewMatcher.run (CodelessMatcher.java:224)</fence>
        <quote p="521">"Looks like this comes from invalid/unexpected values delivered by your codeless configuration
JSON data."</quote>
        <p p="522">**Server-delivered JSON config → `Enum.valueOf` → uncaught `IllegalArgumentException` → hard
crash in production.** This is the exact requested pattern, from Meta's own SDK.</p>
        <p p="523">**The standard mitigation, already enabled, still failing.** URL:
https://github.com/joelittlejohn/jsonschema2pojo/issues/728 — accessed 2026-08-09
(byte-verified):</p>
        <quote p="524">"If I add a new value to an existing Enum and start returning it from my service any client who
has not upgraded to the latest version of the JAR containing the new Enum will start getting
exceptions during deserialization **even if the DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL
has been set to enabled**."</quote>
      </section>
      <section title="1.15.7 Q4 — forced upgrade is published as routine, without apology">
        <p p="525">Slack (https://slack.com/help/articles/1500001836081-Slacks-deprecation-schedule — accessed
2026-08-09, byte-verified):</p>
        <quote p="526">"To allow for innovation and keep customer data safe and secure, Slack updates its system
requirements every six months in May and November."</quote>
        <quote p="527">"When a Slack desktop app version is no longer supported, you'll receive a notification prompting
you to upgrade your app version to continue using Slack. **You won't be able to access Slack until
you upgrade.**"</quote>
        <p p="528">Published durations: desktop/mobile apps "12-18 months from release"; iOS "2.5-3 years from
release"; Android "4.5-5 years from release".</p>
        <p p="529">Signal (https://support.signal.org/hc/en-us/articles/5109141421850-Supporting-Older-Operating-Systems
— accessed 2026-08-09, byte-verified):</p>
        <quote p="530">"you'll be able to open the Signal app but **you won't be able to send or receive messages or calls
until you've upgraded** the operating system."</quote>
        <p p="531">Valve/Steam shows the opposite discipline — a protocol change gated on *observed* adoption
(https://steamcommunity.com/discussions/forum/14/2974028351344359625/, FletcherDunnValve,
2020-12-07 — accessed 2026-08-09, byte-verified):</p>
        <quote p="532">"When a client queries a server, it will begin by sending an A2S_INFO packet, **formatted exactly
as before**."</quote>
        <quote p="533">"**Since not all players may have an updated Steam client and understand this handshake, it is not
recommend to enable this at this time, except for testing. We will post again when the vast
majority of users are running clients that understand the new protocol**, and enabling the new
protocol is safe."</quote>
      </section>
      <section title="1.15.8 ★ Discord — the strongest revealed preference in the study">
        <p p="534">URL: https://docs.discord.com/developers/reference — accessed 2026-08-09 (byte-verified).</p>
        <quote p="535">"Some API and Gateway versions are now non-functioning, and are labeled as discontinued in the
table below for posterity. Trying to use these versions will fail and return 400 Bad Request."</quote>
        <quote p="536">"Omitting the version number from the route will route requests to the current default version
(marked below)."</quote>
        <p p="537">The raw table markup places the ✓ Default marker on **version 6, whose Status is `Deprecated`**;
every other row's Default cell is empty. **Discord's unversioned default route has been frozen
on a deprecated version for years, because moving it would break clients that never named a
version.** Compatibility of already-deployed consumers outranks pointing the default at current.
That is the single most transferable governance decision found — and it is the argument for
requiring a version stamp rather than allowing an unversioned default at all.</p>
      </section>
    </section>
  </section>
  <section title="§2 CROSS-SUBJECT TABLE">
    <p p="538">Rows = questions, columns = subjects, split into two tables for width. `NF` = NOT FOUND
(searched, no authoritative answer — see the named subsection for what was searched).
Every cell is backed by a verbatim quote in §1.</p>
    <section title="2.1 Consumer platforms with mobile clients">
      <table p="539">
        <tr>
          <td></td>
          <td>**Meta / Facebook**</td>
          <td>**Twitter / X**</td>
          <td>**Badoo / Bumble**</td>
          <td>**Netflix**</td>
          <td>**LinkedIn**</td>
          <td>**Uber**</td>
          <td>**Airbnb**</td>
        </tr>
        <tr>
          <td>**Q1 Version or versionless**</td>
          <td>**Both.** Internal mobile GraphQL "version free"; external Graph API explicitly dated-versioned</td>
          <td>**Versioned.** `/2/` in path; v1→v1.1 was a hard migration</td>
          <td>**Versionless, emphatically.** "There is no v2" / "At Badoo we never used this versioning approach"</td>
          <td>Versionless; evolution by deprecation</td>
          <td>**Both.** Versionless internal (CI-gated); versioned external after the unversioned model failed</td>
          <td>**Versionless.** "There should only be one version of the world"; "should not need semantic versioning"</td>
          <td>One shared schema, no version stated; never says "versionless"</td>
        </tr>
        <tr>
          <td>**Q2 Who is tolerant**</td>
          <td>Neither — **the client never receives what it did not ask for**</td>
          <td>Server constrained; client tolerance of added/reordered fields assumed but never stated</td>
          <td>**Client declares, server adapts.** `supported_features` / `supported_banners`</td>
          <td>Server (BFF → federated graph)</td>
          <td>**Server**, deployed first and bilingual</td>
          <td>Codec, symmetric; server is app-version-aware</td>
          <td>**Backend, explicitly.** "the client displays it agnostic of the data it contains"</td>
        </tr>
        <tr>
          <td>**Q3 Enum growth**</td>
          <td>NF for Graph API. Internally Thrift codegen historically **crashed**</td>
          <td>**NF — in neither the breaking nor non-breaking list.** Internally Scrooge `apply` throws; `getOrUnknown` opt-in</td>
          <td>**Prevention, not recovery.** Client sends `supported_banners`; unknown banner shown as the failure state</td>
          <td>NF. DGS client enables Jackson's unknown-enum default; no evidence codegen arms it</td>
          <td>⭐ **"Think of adding enum symbols as backward incompatible."** `$UNKNOWN` generated but "cannot guarantee"</td>
          <td>Binary Thrift permissive; **JSON-by-name strict**. No UNKNOWN convention</td>
          <td>**NF** — "unknown" absent from the SDUI article entirely, on a union+enum architecture</td>
        </tr>
        <tr>
          <td>**Q4 Forced upgrade**</td>
          <td>NF; "three years"/"~1,000 versions" is evidence against</td>
          <td>⭐ **Yes, executed** — blackout tests → `HTTP 410 Gone`</td>
          <td>**Yes, both** — "Suggest upgrading" / "Force upgrading"</td>
          <td>⭐ **Normal** — devices EOL'd when they "can no longer get necessary updates"</td>
          <td>External: deprecated version header errors</td>
          <td>NF (secondary: built it, never used it — "$100 million per year")</td>
          <td>**NF** — old installs are reached, never evicted</td>
        </tr>
        <tr>
          <td>**Q5 Deprecation clock**</td>
          <td>**2 years** (Graph API)</td>
          <td>**1 yr deprecated / ≥2 yrs total**; v1→v1.1 was 6 months announced, ~9 actual</td>
          <td>**None, and says why** — "not always possible"</td>
          <td>**None — usage-gated** ("once the stats show… no longer used")</td>
          <td>**1 year minimum** (public API)</td>
          <td>NF</td>
          <td>NF (mobile)</td>
        </tr>
        <tr>
          <td>**Q6 CI gate**</td>
          <td>NF</td>
          <td>NF</td>
          <td>Human review + **adoption dashboard** (version × flag matrix)</td>
          <td>Governance + linting, **no published refusal**</td>
          <td>⭐ **Yes** — 4–5 levels, `equivalent` in CI</td>
          <td>⭐ **Yes** — "prevented by a CI job"; `prototool break check`</td>
          <td>Thrift only; **not** for the mobile GraphQL/SDUI contract</td>
        </tr>
      </table>
    </section>
    <section title="2.2 Governance corpora, formats, and counter-examples">
      <table p="540">
        <tr>
          <td></td>
          <td>**Google AIP**</td>
          <td>**Kubernetes**</td>
          <td>**Stripe**</td>
          <td>**GitHub GraphQL**</td>
          <td>**GraphQL (movement)**</td>
          <td>**Thrift (2007 paper)**</td>
          <td>**Discord / Slack**</td>
        </tr>
        <tr>
          <td>**Q1**</td>
          <td>**Hybrid** — major version in path/package, "must not expose minor or patch"; versionless within</td>
          <td>Explicit API groups; "elements may only be removed by incrementing the version"</td>
          <td>**Dated rolling versions, account-pinned**; compatible "with every version… since 2011"</td>
          <td>Versionless schema + published breaking-change schedule</td>
          <td>**Versionless by design** — "GraphQL avoids versioning by design"</td>
          <td>Versionless; identity via **field IDs**</td>
          <td>Discord: numbered, and the **unversioned default is frozen on a deprecated version**</td>
        </tr>
        <tr>
          <td>**Q2**</td>
          <td>⭐ **Server, normatively** — "Old clients **must** be able to work against newer servers". **No** tolerant-reader rule exists</td>
          <td>**Six normative rules**; "Existing clients need not be aware of your change"</td>
          <td>Server, forever (pinning). Client told to "gracefully handle unfamiliar event types"</td>
          <td>Server</td>
          <td>Neither — the query is the mechanism</td>
          <td>⭐ **Reader** — "the generated code can use the type specifier to skip the unknown field without any error"</td>
          <td>Slack: **client must upgrade or lose access**</td>
        </tr>
        <tr>
          <td>**Q3**</td>
          <td>**Not breaking** — but AIP-216 admits "can break existing user code". Real mitigation is a **budget**: "no more than once a year"</td>
          <td>⭐ **"Adding a new value to an enumerated set is *not* a compatible change."** Direct contradiction of Google</td>
          <td>**Not on the compatible list.** Nearest: "gracefully handles unfamiliar event types"</td>
          <td>⭐ **"Dangerous"** — a third category: "won't break existing queries but could affect runtime behavior"</td>
          <td>`VALUE_ADDED_TO_ENUM` = *dangerous*: "may break existing clients that were not programming defensively"</td>
          <td>**NF** — the paper's four-case analysis covers fields only</td>
          <td>NF</td>
        </tr>
        <tr>
          <td>**Q4**</td>
          <td>⭐ **No true mechanism.** Play's "immediate" update is declinable; fallback is "prompt the user to close the app"</td>
          <td>N/A</td>
          <td>No</td>
          <td>No</td>
          <td>No</td>
          <td>N/A</td>
          <td>⭐ **Routine, unapologetic** — "You won't be able to access Slack until you upgrade"</td>
        </tr>
        <tr>
          <td>**Q5**</td>
          <td>**12 months** contractually; beta 180 days; "in-place breaking change… requires the approval of the API Governance team"</td>
          <td>**GA: never within a major.** Beta: 9 months / 3 releases. Alpha: no notice</td>
          <td>**None published — effectively forever**</td>
          <td>**3 months minimum**, quarterly effective dates, published forward schedule</td>
          <td>**None. `@deprecated` has a reason and no duration**</td>
          <td>NF</td>
          <td>Slack: 12–18 mo apps, 4.5–5 yrs Android. Discord: no durations</td>
        </tr>
        <tr>
          <td>**Q6**</td>
          <td>⭐ **NO official gate.** `aip0180` absent from the linter; detector "is not an officially supported Google project"</td>
          <td>Round-trip tests</td>
          <td>NF</td>
          <td>NF</td>
          <td>`buf`, `oasdiff`, `graphql-inspector` (static); **Apollo requires client telemetry and fails closed without it**</td>
          <td>N/A</td>
          <td>NF</td>
        </tr>
      </table>
    </section>
    <section title="2.3 The three patterns worth naming">
      <list ordered="true" p="541">
        <item>**Versionless is bought, not free.** Every organisation that stays versionless pays for it with
   something the questioner's case cannot supply: a query (Meta, GraphQL), a negotiation handshake
   (Badoo), a hard CI gate (LinkedIn, Uber), or an observable client population (Netflix).</item>
        <item>**The same company versions when it loses control of the consumer.** Meta: versionless
   internally, dated versions externally. LinkedIn: versionless internally, versioned externally
   *after publicly stating the unversioned model failed*. This is the strongest single signal in
   the study, and it points directly at the data-at-rest verdict.</item>
        <item>**Nobody solved the enum question.** Four organisations independently invented a *third*
   category for it (GitHub "dangerous", graphql-js `DangerousChangeType`, LinkedIn
   `WIRE_COMPATIBLE`, Google's request/response asymmetry) rather than call it compatible or
   incompatible.</item>
      </list>
    </section>
  </section>
  <section title="§3 THE ENUM QUESTION, IN DEPTH">
    <p p="542">This is the question the research answers most clearly, and the answer is worse than the
folklore suggests.</p>
    <section title="3.1 The headline: the two most respected corpora flatly contradict each other">
      <p p="543">**Google says adding an enum value is not a breaking change.**
https://google.aip.dev/216 — accessed 2026-08-09:</p>
      <quote p="544">"Even though adding states to an existing states enum _can_ break existing user code, adding
states is not considered a breaking change."</quote>
      <p p="545">**Kubernetes says adding an enum value is not a compatible change.**
https://raw.githubusercontent.com/kubernetes/community/main/contributors/devel/sig-architecture/api_changes.md
— accessed 2026-08-09:</p>
      <quote p="546">"Adding a new value to an enumerated set is *not* a compatible change. Clients which assume
they know how to handle all possible values of a given field will not be able to handle the new
values."</quote>
      <p p="547">**LinkedIn agrees with Kubernetes, in a document written specifically to correct the opposite
belief.** https://linkedin.github.io/rest.li/modeling/compatibility_check — accessed
2026-08-09, under the heading *"Why is Adding to an Enum Considered Backwards Incompatible?"*:</p>
      <quote p="548">"Many developers are surprised that adding to an enum is considered a backwards incompatible
change."</quote>
      <quote p="549">"it's important to think of adding enum symbols as backward incompatible"</quote>
      <p p="550">These are not addressing different questions. They are the same question, answered in
opposite directions, by three of the most carefully governed API corpora in existence — two of
which (Kubernetes, Rest.li) went to the trouble of writing down *why the intuitive answer is
wrong*, and one of which (Google) states the intuitive answer while conceding in the same
paragraph that it breaks real code. **There is no industry consensus on the sharpest question
here.** Anyone who tells you there is has read only one of the three documents.</p>
      <p p="551">A fourth position exists and is arguably the most honest: **invent a third category.** GitHub
publishes it as policy — "Adding an enum value is an example of a dangerous change" — the
reference `graphql-js` implementation encodes it as `DangerousChangeType.VALUE_ADDED_TO_ENUM`,
and LinkedIn independently arrived at the same place *in production code*, creating a
`WIRE_COMPATIBLE` compatibility level whose sole member is `ENUM_VALUE_ADDED`, commented:</p>
      <quote p="552">"New enum value added, which is wire compatible change. However, old readers may not be able to
handle it."
— https://raw.githubusercontent.com/linkedin/rest.li/master/data/src/main/java/com/linkedin/data/schema/compatibility/CompatibilityMessage.java
— accessed 2026-08-09</quote>
      <p p="553">Four organisations, working independently, could not fit "added an enum value" into either
bucket. That is the strongest available evidence that the question is genuinely ill-posed under
a binary compatible/incompatible model.</p>
      <p p="554">What reconciles the positions is a difference in *who absorbs the cost*, not a difference in
fact. All agree the old client can break. Google chooses to ship anyway and discharge the
obligation through documentation; Kubernetes and LinkedIn choose to call it incompatible and
force a migration. Google's own text concedes the mechanism it is choosing not to use:</p>
      <quote p="555">"We ultimately can not control this behavior, but API documentation **should** actively
encourage users to code against state enums with the expectation that they may receive new
values in the future."</quote>
      <p p="556">And LinkedIn states the limit of even the best mechanism, which is the sentence that should
govern any decision made on the strength of this report:</p>
      <quote p="557">"it may be that they cannot do anything other than fail if they encounter a enum symbol they do
not recognize"
— https://linkedin.github.io/rest.li/modeling/compatibility_check — accessed 2026-08-09</quote>
    </section>
    <section title="3.2 What actually happens on the wire, per format">
      <p p="558">The behaviour is entirely determined by whether the format's enums are **open** (the decoded
value can hold a number/string the code does not know) or **closed** (it cannot).</p>
      <table p="559">
        <tr>
          <td>Format</td>
          <td>Old reader receives a new enum value</td>
          <td>Loud or silent?</td>
        </tr>
        <tr>
          <td>proto3 / editions binary (**open**)</td>
          <td>"Open enums will parse the value `2` and store it directly in the field." Accessor "will report the field as being _set_". Round-trips intact.</td>
          <td>Neither — it works</td>
        </tr>
        <tr>
          <td>proto2 binary (**closed**)</td>
          <td>"Closed enums will parse the value `2` and store it in the message's unknown field set." Accessor reports the field **unset**, returns the default.</td>
          <td>**Silent** — worst case</td>
        </tr>
        <tr>
          <td>proto2 `repeated` closed enum</td>
          <td>Unknown values go to the unknown-field set and on reserialize are appended, "**but not in their original place in the list**" — `[0,2,1,2]` reads as `[0,1]` and rewrites as `[0,1,2,2]`</td>
          <td>**Silent reordering**</td>
        </tr>
        <tr>
          <td>ProtoJSON</td>
          <td>**Unspecified by the spec.** Default in major implementations is to **throw**.</td>
          <td>**Loud crash**</td>
        </tr>
        <tr>
          <td>Avro</td>
          <td>"if the writer's symbol is not present in the reader's enum and the reader has a default value, then that value is used, **otherwise an error is signalled**"</td>
          <td>**Loud** unless `default` pre-declared</td>
        </tr>
        <tr>
          <td>GraphQL (typed clients)</td>
          <td>Codegen-dependent. Apollo Kotlin synthesises an `UNKNOWN__` case; exhaustive `switch`/`when` in hand-written TS unions crashes.</td>
          <td>Varies</td>
        </tr>
        <tr>
          <td>Thrift (historic codegen)</td>
          <td>`findByValue` returns null; "clients receiving a `Baz` with the new enum value will crash"</td>
          <td>**Loud crash**</td>
        </tr>
        <tr>
          <td>JSON Schema</td>
          <td>No writer/reader schema concept exists. `enum` is a closed set constraint; a validator rejects the new value.</td>
          <td>**Loud rejection**</td>
        </tr>
      </table>
      <p p="560">Sources for the above, all accessed 2026-08-09: https://protobuf.dev/programming-guides/enum/;
https://avro.apache.org/docs/1.12.0/specification/; https://github.com/microsoft/thrifty/issues/84;
https://json-schema.org/understanding-json-schema/reference/enum.</p>
      <p p="561">**The ProtoJSON gap deserves separate emphasis, because it is the one that catches people.**
ProtoJSON serialises enums by *name*
(https://protobuf.dev/programming-guides/json/ — accessed 2026-08-09):</p>
      <quote p="562">"The name of the enum value as specified in proto is used."
"Parsers accept both enum names and integer values."</quote>
      <p p="563">but the specification does not say what a parser does with a name it does not recognise.
Protobuf's own tracker documents this as a known, acknowledged specification gap —
https://github.com/protocolbuffers/protobuf/issues/7392 (status: closed, label
`documentation`) — accessed 2026-08-09:</p>
      <quote p="564">"Need specification for how to parse unrecognized enums in JSON"
"when it sees an unrecognized enum value that has been serialized to a JSON string, it throws
an exception"
"Could someone please provide a specification for what a protobuf library should do when it sees
an unrecognized Enum value serialized in a JSON string?"
"when this field, which is totally ignored, gets a new enum value, the v12.5 version of the app
breaks"</quote>
      <p p="565">So the widely repeated maxim "adding an enum value is safe" is **true for proto3 binary and
false for JSON**, and every implementation requires an explicit opt-in flag
(`protojson.UnmarshalOptions.DiscardUnknown` in Go, `JsonFormat.Parser.ignoringUnknownFields()`
in Java, `ignoreUnknownFields` in protobuf-es) to make it true. **If the transport is JSON,
enum growth is breaking by default.**</p>
    </section>
    <section title="3.2a ★ Every mainstream JSON deserializer throws by default. Tolerance is opt-in everywhere.">
      <p p="566">This is the most operationally important finding in the report, and it holds across four
independent language ecosystems plus Twitter's own internal codegen. All accessed 2026-08-09.</p>
      <table p="567">
        <tr>
          <td>Library</td>
          <td>Default on unknown enum value</td>
          <td>The opt-in</td>
        </tr>
        <tr>
          <td>**Jackson** (Java)</td>
          <td>throws</td>
          <td>`READ_UNKNOWN_ENUM_VALUES_AS_NULL` — "Feature is disabled by default."</td>
        </tr>
        <tr>
          <td>**kotlinx.serialization**</td>
          <td>throws</td>
          <td>`coerceInputValues` — "`false` by default."</td>
        </tr>
        <tr>
          <td>**Moshi** (Kotlin/Java)</td>
          <td>throws</td>
          <td>`EnumJsonAdapter.withUnknownFallback(...)`</td>
        </tr>
        <tr>
          <td>**Swift `Codable`**</td>
          <td>`dataCorrupted` decoding error</td>
          <td>hand-written `init(from:)`</td>
        </tr>
        <tr>
          <td>**Swift enums (language)**</td>
          <td>traps at runtime</td>
          <td>`@unknown default`</td>
        </tr>
        <tr>
          <td>**Twitter Scrooge**</td>
          <td>`apply` "Throws NoSuchElementException"</td>
          <td>`getOrUnknown`</td>
        </tr>
        <tr>
          <td>**Apollo Kotlin**</td>
          <td>**tolerant** — `UNKNOWN__` generated automatically</td>
          <td>(opt-*out*)</td>
        </tr>
        <tr>
          <td>**LinkedIn Rest.li**</td>
          <td>**tolerant** — `$UNKNOWN` generated automatically</td>
          <td>(opt-*out*)</td>
        </tr>
      </table>
      <p p="568">Sources: Jackson `DeserializationFeature` javadoc
(https://fasterxml.github.io/jackson-databind/javadoc/2.13/com/fasterxml/jackson/databind/DeserializationFeature.html):</p>
      <quote p="569">"Feature that allows unknown Enum values to be parsed as null values. If disabled, unknown Enum
values will throw exceptions. … **Feature is disabled by default.**"</quote>
      <p p="570">kotlinx.serialization `coerceInputValues`
(https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/coerce-input-values.html):</p>
      <quote p="571">"Enables coercing incorrect JSON values in the following cases: … Property type is an enum type,
but JSON value contains an unknown enum member." … "`false` by default."</quote>
      <p p="572">Moshi `EnumJsonAdapter` (raw source, byte-verified) — the default path:</p>
      <fence lang="kotlin" p="573">if (!useFallbackValue) {
  val name = reader.nextString()
  throw JsonDataException("Expected one of ${nameStrings.toList()} but was $name at path ${reader.path}")
}</fence>
      <p p="574">kotlinx.serialization issue #3071 (open, byte-verified):
&gt; "Currently, kotlinx.serialization fails with an exception when deserializing an enum value that is
&gt; not present in the target enum class. This makes it difficult to handle backward/forward
&gt; compatibility when enums evolve (e.g., when a server adds a new enum constant not yet known by the
&gt; client)."</p>
      <p p="575">And issue #1303 documents something worse than a clean error: "if an unknown enum value is
encountered, the deserializer throws an `ArrayIndexOutOfBoundsException` on the JVM instead of a
`SerializationException`."</p>
      <p p="576">Swift codegen, the same failure (https://github.com/swagger-api/swagger-codegen/issues/7304):
&gt; "Later on I decide to add another vehicle type in my backend… my app version was released earlier
&gt; will not be able to deserialize Vehicle. It will throw an error even if the type is not
&gt; 'required':" → `dataCorrupted(… "Cannot initialize ModelType from invalid String value Something")`</p>
      <p p="577">**Swift took this seriously enough to change the language.** SE-0192, "Handling Future Enum
Cases" (https://raw.githubusercontent.com/swiftlang/swift-evolution/main/proposals/0192-non-exhaustive-enums.md
— byte-verified):</p>
      <quote p="578">"Currently, adding a new case to an enum is a source-breaking change, something that's at odds
with Apple's established process for evolving APIs. This proposal aims to distinguish between enums
that are _frozen_ (meaning they will never get any new cases) and those that are _non-frozen,_ and
to ensure that clients handle any future cases when dealing with the latter."</quote>
      <quote p="579">"It's well-established that many enums need to grow new cases in new versions of a library… This
all implies that library authors *must* have a way to add new cases to enums without breaking
binary compatibility."</quote>
      <quote p="580">"A program will trap at run time if an unknown enum case is actually encountered."</quote>
      <p p="581">**The conclusion for anyone publishing data that third parties decode:** the *default* behaviour
of the tooling your consumers most likely use is **to fail**. You cannot assume tolerance; the
industry's defaults are against you. The two libraries in the table that are tolerant by default
(Apollo Kotlin, Rest.li) are both cases where the *schema publisher also wrote the client's code
generator*. Where the publisher does not control codegen, the default is a crash.</p>
    </section>
    <section title="3.3 Does anyone mandate an UNKNOWN fallback? Essentially no.">
      <p p="582">This was the specific question, and the answer is a clear negative across every corpus
examined.</p>
      <list ordered="false" p="583">
        <item>**Google does not.** `_UNSPECIFIED = 0` means *unset*, not *unrecognised*. AIP-126 (accessed
  2026-08-09): "The first value of the enum **should** be the name of the enum itself followed by
  the suffix `_UNSPECIFIED`." An `UNKNOWN` sentinel is offered as an explicit *optional exception*:
  "An exception to this rule is if there is a clearly useful zero value." And AIP-216 says the
  zero value "**should not** actually be used." No mandate.</item>
        <item>**GraphQL does not.** The spec constrains the server only — "GraphQL services must return one
  of the defined set of possible values" — and says nothing about a client on an older schema.</item>
        <item>**OpenID Connect explicitly declines to**, for exactly this case: unknown `display`/`prompt`
  values → "it MAY return an error or it MAY ignore it" (§3.1.2.6, accessed 2026-08-09).</item>
        <item>**Kubernetes comes closest, and it is only a SHOULD on documentation**: "document that
  expectation clearly in the API field description in the first release the field is made
  available, and describe how clients should treat an unknown value. Clients should treat such
  sets of values as potentially open-ended."</item>
        <item>**Avro mandates nothing but *provides* the only real mechanism** — the reader-side enum
  `default`. And its constraint is brutal: it must already be in the old reader's schema.</item>
        <item>**Stripe issues the closest thing to a normative client instruction**, and only for event
  types: "Make sure that your webhook listener gracefully handles unfamiliar event types."
  (https://docs.stripe.com/upgrades — accessed 2026-08-09.)</item>
      </list>
      <p p="584">**Where the UNKNOWN fallback actually gets mandated is in codegen, not in specs.** Apollo
Kotlin synthesises an `UNKNOWN__` case into every generated enum precisely so that old clients
survive server-side additions; the community request to *remove* it
(https://github.com/apollographql/apollo-kotlin/issues/6243 — accessed 2026-08-09) exists
because developers find the extra `when` branch annoying. That is the shape of the real-world
solution: **a vendor who controls the client's code generator can impose tolerance that no
specification is willing to require.** Note the precondition — *controls the client's code
generator*.</p>
      <p p="585">**Verified from Apollo Kotlin's own code generator (PRIMARY, source code).** URL:
https://raw.githubusercontent.com/apollographql/apollo-kotlin/main/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo/compiler/codegen/kotlin/schema/EnumAsEnumBuilder.kt
— accessed 2026-08-09. The generated KDoc strings are:</p>
      <quote p="586">"Auto generated constant for unknown enum values"</quote>
      <quote p="587">"Returns the [%T] that represents the specified [rawValue]. Note: unknown values of [rawValue]
will return [UNKNOWN__]. You may want to update your schema instead of calling this function
directly."</quote>
      <p p="588">`safeValueOf` returns the matching entry, or `UNKNOWN__` when `withUnknown` is true, or throws
when it is false. So the tolerance is real, it is generated automatically into every enum, and
it is opt-out-able — which is exactly the level of control a vendor has over its own client and
does not have over anyone else's.</p>
      <p p="589">**NOT FOUND:** a verbatim maintainer statement of the *rationale* for `UNKNOWN__` in a
maintainer's own words. Fetched https://github.com/apollographql/apollo-kotlin/issues/6243
(accessed 2026-08-09); the page carried the feature request to remove it but no maintainer
reply explaining the original design decision. The behaviour is primary-sourced from the
generator above; the stated reasoning is not.</p>
    </section>
    <section title="3.3a The one published prescription for uncontrolled consumers">
      <p p="590">Only one organisation in the study wrote down what to do when **you do not know your clients
and cannot coordinate with them**. It is LinkedIn, and it is worth isolating because it is
the exact case at hand.
https://linkedin.github.io/rest.li/modeling/compatibility_check — accessed 2026-08-09:</p>
      <quote p="591">"If a new symbols is to be added, a migration strategy for adding the enum symbol(s) must be
performed just as for any other backward incompatible change. **Note that this is only possible
when all clients are known and it is possible to coordinate changes with them. If this is not the
case, one should consider making a backward compatible change (such as adding a new optional
field containing a new enum field with more symbols) and supporting the existing clients, with
the existing enum symbols, indefinitely.**"</quote>
      <p p="592">Unpacked, the prescription is:</p>
      <list ordered="true" p="593">
        <item>**Do not grow the existing enum.** Ever, once uncontrolled readers exist.</item>
        <item>**Add a new optional field** carrying the wider vocabulary alongside the old one.</item>
        <item>**Keep populating the old field with the old vocabulary indefinitely**, choosing the least-bad
   legacy value for records whose true value is new.</item>
        <item>Old readers keep reading the old field and never see a symbol they don't know. New readers
   read the new field.</item>
      </list>
      <p p="594">The cost is permanent duplication and a lossy projection into the legacy vocabulary. The
benefit is that it is the only strategy on this list that does not require the reader to have
done anything in advance. **Everything else in §3.4 requires foresight the old artifact may not
have had; this requires foresight only from the publisher.**</p>
      <p p="595">LinkedIn also states, in the same document, that this gets strictly worse once the data is
persisted rather than transported:</p>
      <quote p="596">"While unknown symbols can be deserialized by older Rest.li consumers (because rest.li does not
require the schema to de-serialize), it doesn't work for data persisted as Avro. Any attempt to
deserialize an avro record containing the new enumeration value with an older schema lacking that
enum will fail."</quote>
    </section>
    <section title="3.4 The three real mitigations, ranked by whether they work">
      <list ordered="true" p="597">
        <item>**Don't use a closed vocabulary at all.** This is Google's actual load-bearing advice, and
   it is the only one that cannot fail. AIP-126 (accessed 2026-08-09): "enums **should** receive
   new values infrequently … a good rule of thumb is no more than once a year. For enums that
   change frequently, the API **should** use a string and document the format." Iceberg, designed
   for evolvable data at rest, has no enum type in its model at all.</item>
        <item>**Make the decoded representation open before you need it** — proto3 open enums, Avro's enum
   `default`, OpenAPI's `x-extensible-enum`, Apollo's `UNKNOWN__`. Every one of these is
   **retroactive-defence-only**: it protects a reader only if that reader already had it. You
   cannot retrofit tolerance onto artifacts already in the wild. This is the single most
   important operational fact in this report.</item>
        <item>**Document that the set is open and hope.** This is what Google and Kubernetes both fall back
   on. It is not a mechanism; it is a request. It works to the extent your consumers read your
   docs, which for anonymous third-party tooling is approximately not at all.</item>
      </list>
    </section>
    <section title="3.5 The generalisation">
      <p p="598">The unknown-*field* problem is solved everywhere and legislated in several places
(Thrift's self-delimiting skip, protobuf's unknown-field set, OIDC's "MUST be ignored", RFC
6709's MBZ warning). The unknown-*enum-value* problem is solved nowhere and legislated
nowhere.</p>
      <p p="599">The reason is structural, and Allen George of Apache Thrift stated it exactly
(https://www.mail-archive.com/dev@thrift.apache.org/msg50731.html — accessed 2026-08-09):</p>
      <quote p="600">"to support forward-compatibility, you have to have the ability to create enum variants
without a named value and encode them onto the wire."</quote>
      <p p="601">An unknown field can be skipped because it is *addressed* — it has an identifier the reader
can step over without understanding. An unknown enum value cannot be skipped, because it is
not a container; it is the value itself, and the reader must *hold* it in a typed slot that,
by construction, enumerates only known values. Every fix amounts to widening that slot in
advance. There is no way to widen it after the fact.</p>
    </section>
  </section>
  <section title="§4 WHAT TRANSFERS TO DATA AT REST, AND WHAT DOES NOT">
    <section title="4.1 The four properties the mobile playbook silently assumes">
      <p p="602">Before the table, name the assumptions, because every verdict below follows mechanically from
which ones the target case violates. The case in question is: **JSON files published into a
git repository, read later by arbitrary third-party tools that cannot be version-negotiated
with, cannot be force-upgraded, and cannot be observed.**</p>
      <table p="603">
        <tr>
          <td>#</td>
          <td>Assumption</td>
          <td>Holds for mobile?</td>
          <td>Holds for JSON-in-git?</td>
        </tr>
        <tr>
          <td>A1</td>
          <td>**The reader asks.** There is a request in which the consumer states what it wants.</td>
          <td>Yes</td>
          <td>**No** — the file is written once, read later, unilaterally</td>
        </tr>
        <tr>
          <td>A2</td>
          <td>**The publisher can see consumers.** Traffic, versions, field usage are measurable.</td>
          <td>Yes</td>
          <td>**No**</td>
        </tr>
        <tr>
          <td>A3</td>
          <td>**The publisher can reach consumers.** Force-upgrade, kill-switch, minimum-version gate.</td>
          <td>Partly</td>
          <td>**No**</td>
        </tr>
        <tr>
          <td>A4</td>
          <td>**The artifact is transient.** Yesterday's response is gone; only the current shape matters.</td>
          <td>Yes</td>
          <td>**No** — the artifact is the deliverable and it persists</td>
        </tr>
      </table>
      <p p="604">A1 is the one people miss, and it is the load-bearing one for GraphQL specifically. Meta's
"nearly 1,000 shipped application versions" works because **each old client sends its old query
and gets exactly the shape it named.** A file cannot do that. A file is a response with no
request. Every additive change lands in the reader's lap whether it wants it or not.</p>
      <p p="605">A4 is the one that inverts the direction of compatibility. API guidance is overwhelmingly about
**backward** compatibility — new server, old client, and the server is the thing that changes.
Data at rest needs **forward** compatibility — old reader, new data — which is the direction
Confluent explicitly warns is unassured, and whose only published remedy is to hide the new
data from old readers:</p>
      <quote p="606">"`FORWARD` or `FORWARD_TRANSITIVE`: there is no assurance that consumers using the new schema
can read data produced using older schemas. Therefore, first upgrade all producers to using the
new schema and **make sure the data already produced using the older schemas are not available
to consumers**, then upgrade the consumers."
— https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
— accessed 2026-08-09</quote>
      <p p="607">In a git repository you cannot make old data unavailable. History is the product.</p>
    </section>
    <section title="4.2 The verdict table">
      <p p="608">Verdicts are deliberately harsh. A practice **transfers** only if it works with no live server,
no telemetry, and no ability to contact the consumer.</p>
      <table p="609">
        <tr>
          <td>Practice (and who publishes it)</td>
          <td>Transfers?</td>
          <td>Reasoning</td>
        </tr>
        <tr>
          <td>**Never remove or rename a field; additive-only growth** — Google AIP-180 "must not be removed"; Thrift §5.3; Kubernetes Rule #1</td>
          <td>**YES — fully**</td>
          <td>Pure property of the artifact. Needs no server, no negotiation, no observation. This is the one practice that is *more* important at rest than in an API, because there is no version bump available as an escape hatch.</td>
        </tr>
        <tr>
          <td>**Never reuse an identifier; tombstone what you delete** — protobuf `reserved 2, 3;`; schema.org `supersededBy`; Kubernetes "constant value … must exist and function until API v1 is removed"</td>
          <td>**YES — fully**</td>
          <td>Static discipline over the schema's own history. schema.org is the proof case: a vocabulary consumed by uncontrolled third parties worldwide that never deletes.</td>
        </tr>
        <tr>
          <td>**Self-describing artifacts: the file carries its own schema/version** — Avro object container files; npm `lockfileVersion`; Thrift §5.4 "add a version header into the data it writes to the file"</td>
          <td>**YES — fully, and it is the only complete solution**</td>
          <td>This is the *substitute* for negotiation. Avro can resolve reader-vs-writer with no server precisely because "the original schema must be provided" with the data. Note that the format most like the target case — `package-lock.json`, a JSON file in a git repo read by tools of many vintages — chose an **explicit version integer**, not versionless evolution.</td>
        </tr>
        <tr>
          <td>**Tolerant reader: ignore unknown members** — OIDC "MUST be ignored"; RFC 6709 §4.2 MBZ; protobuf unknown-field set</td>
          <td>**PARTIAL — you can mandate it, you cannot enforce it**</td>
          <td>The *rule* transfers perfectly and costs nothing to state. But you have no way to verify third-party tools obey it, and no way to find out when they don't (A2 fails). Publish it as a normative requirement in the format spec, then design as if half your readers ignore the requirement.</td>
        </tr>
        <tr>
          <td>**Explicit version field / pinning at write time** — Stripe account pinning; npm `lockfileVersion`; Kubernetes API groups</td>
          <td>**PARTIAL → mostly YES**</td>
          <td>Stripe's *mechanism* (pin the account, serve old shapes forever) needs a server. But its *idea* — the artifact records which contract it was written against — transfers completely and is exactly what `lockfileVersion` does. The part that does not transfer is Stripe's ability to keep a translation layer running; at rest you must instead keep the old shape *readable*, which is cheaper.</td>
        </tr>
        <tr>
          <td>**Deprecate-but-keep-serving** — GraphQL `@deprecated`; Meta "deprecated but continue to function"</td>
          <td>**PARTIAL**</td>
          <td>Marking a field deprecated in a published schema transfers. What does not transfer is the *lifecycle*: `@deprecated` carries a reason and **no duration**, and at rest there is no moment at which you learn it is safe to stop. In practice "deprecate" at rest collapses into "keep forever", i.e. into row 1.</td>
        </tr>
        <tr>
          <td>**Versionless / additive-only-forever, no version number** — graphql.org "GraphQL avoids versioning by design"; Meta "removes the need for an incrementing version number"</td>
          <td>**NO**</td>
          <td>Depends entirely on A1. GraphQL is versionless *because the client names its fields*; the server never sends an unrequested shape. A file has no query. Strip A1 away and "versionless" degrades to "unversioned", which is strictly worse than versioned — the reader cannot even tell which contract it is holding. **The single most commonly mis-transferred practice in this whole corpus.**</td>
        </tr>
        <tr>
          <td>**Force-upgrade / minimum-version gate / kill-switch**</td>
          <td>**NO**</td>
          <td>A3 fails absolutely. Worth noting it barely holds *for mobile either*: Google's strongest mechanism is declinable, and Google's own documented fallback for an app that cannot function without the update is to *"prompt the user to close the app"* (https://developer.android.com/guide/playcore/in-app-updates/kotlin-java — accessed 2026-08-09).</td>
        </tr>
        <tr>
          <td>**Traffic-driven safety checks** — Apollo GraphOS operations checks</td>
          <td>**NO**</td>
          <td>A2 fails absolutely. Apollo's rules are literally phrased "used by at least one operation", and with no metrics "all potentially dangerous schema changes result in a failed check". With zero observability the tool has no signal and degrades to refusing everything.</td>
        </tr>
        <tr>
          <td>**Time-boxed deprecation windows** — Meta 2 years; GitHub 3 months + quarterly; Google 12 months; Kubernetes 9 months / 3 releases</td>
          <td>**NO (as written); YES only as a promise you can never collect on**</td>
          <td>Every published window measures *time since announcement*, and assumes the consumer sees announcements (A3) and that you learn when the last old client leaves (A2). Neither holds. A file written in 2026 may first be read in 2031 by a tool pinned in 2027. **Kubernetes is the one corpus that draws the right distinction**: you may stop *serving* an old version, but "the API server must remain capable of decoding/converting previously persisted data from storage." Adopt that split; discard the clocks.</td>
        </tr>
        <tr>
          <td>**Stop serving an old shape once usage hits zero**</td>
          <td>**NO**</td>
          <td>Requires A2. There is no observable zero.</td>
        </tr>
        <tr>
          <td>**Server-side translation / conversion layer** — Kubernetes round-trip conversion; Stripe version-shim</td>
          <td>**NO for the publisher; YES only if relocated into the reader**</td>
          <td>Requires a live server that sees the request. At rest the only equivalent is shipping a migration tool and hoping consumers run it — which is A3 again.</td>
        </tr>
        <tr>
          <td>**Compatibility gating in CI (static)** — `buf breaking`, `oasdiff`, `graphql-inspector`, Avro `SchemaCompatibility`, Confluent `test-local-compatibility`</td>
          <td>**YES — fully**</td>
          <td>The one piece of enforcement that survives intact. All of these are pure schema-vs-schema diffs needing no server and no telemetry. Confluent even added a local goal specifically to remove the server dependency. **If exactly one practice is adopted from this report, adopt this one** — it is the only mechanism that catches the mistake while it is still cheap.</td>
        </tr>
        <tr>
          <td>**UNKNOWN/open-enum fallback in generated clients** — Apollo `UNKNOWN__`; proto3 open enums; `x-extensible-enum`</td>
          <td>**NO — you cannot impose it on third-party readers**</td>
          <td>This works for Apollo because Apollo *writes the client's decoder*. You do not write your consumers' decoders. What **does** transfer is the negative: knowing this, do not create closed vocabularies you intend to grow (§4.3).</td>
        </tr>
        <tr>
          <td>**Enum-growth budget: "no more than once a year", else use a documented string** — AIP-126</td>
          <td>**YES — fully, and it is the most valuable single line in the corpus**</td>
          <td>Pure design-time discipline. No server, no telemetry, no consumer contact. Google's own load-bearing mitigation turns out to be the one that survives translation completely intact.</td>
        </tr>
        <tr>
          <td>**Parallel-field enum growth: freeze the old enum, add a new optional field with the wider vocabulary, populate both forever** — LinkedIn Rest.li</td>
          <td>**YES — fully. The single most directly applicable practice found.**</td>
          <td>Explicitly prescribed by LinkedIn *for the case where clients are unknown and uncoordinated*. Requires nothing of the reader and nothing of a server. Costs permanent duplication and a lossy legacy projection. This is the answer to "what do I actually do when I must add a value."</td>
        </tr>
        <tr>
          <td>**Usage-gated deprecation: remove only once telemetry shows zero use** — Netflix</td>
          <td>**NO**</td>
          <td>The correct design when clients cannot be force-upgraded, and completely unavailable at rest: A2 fails. Netflix can do this *because* it sees every field access. A published file is read invisibly.</td>
        </tr>
        <tr>
          <td>**Server-driven UI: move the decision to the server so the client needs no schema knowledge** — Airbnb GP/SDUI</td>
          <td>**NO**</td>
          <td>The most extreme form of "let the server absorb everything", and it inverts to nothing at rest — there is no server in the loop at read time. Note that even Airbnb qualifies it: features launch to old releases "assuming the response is supported", and their testing "still doesn't catch everything."</td>
        </tr>
      </table>
    </section>
    <section title="4.3 The blunt summary">
      <p p="610">Of seventeen practices, **six transfer fully** (additive-only; tombstoning; self-describing
artifacts; static CI gating; the enum budget; LinkedIn's parallel-field enum growth), **three
transfer partially** (tolerant-reader as an unenforceable norm; explicit version stamping;
deprecation marking without a clock), and **eight do not transfer at all** (versionless
evolution, force-upgrade, traffic-driven checks, timed deprecation windows, usage-gated
removal, server-side translation, generated UNKNOWN fallbacks, server-driven UI).</p>
      <p p="611">**A useful sorting rule falls out of the table:** a practice transfers if and only if it is a
property of *the artifact or of the publisher's own discipline*. Every practice that is a
property of *the relationship* between publisher and consumer — negotiation, observation,
coercion, translation — dies on contact with a file in a git repository.</p>
    </section>
    <section title="4.4 How to keep the format EVOLVABLE — the constructive answer">
      <p p="612">§4.2 reads as a list of prohibitions. It should not be mistaken for "freeze the format forever."
None of the organisations studied froze anything: Meta ships continuously, Badoo ships nine
releases a week, Kubernetes changes its API every quarter. **What they bought was not stasis, it
was the ability to change without asking permission from consumers they cannot reach.** The
research supports a specific, buildable design for that, assembled from the practices that
survived §4.2. Every element below is primary-sourced above.</p>
      <section title="4.4.1 Stamp every artifact — this is the enabling move, not a concession">
        <p p="613">npm's `lockfileVersion` is the closest published analogue to the case at hand, and it is an
explicit integer at the top of a JSON file in a git repository. **Discord supplies the negative
proof**: its unversioned default route has been frozen on a *deprecated* version for years,
because there is no safe way to move a default that consumers never named. A format without a
version stamp cannot evolve; it can only accrete.</p>
        <p p="614">The stamp is what converts every later decision from "will this break someone?" into "which
readers does this affect?" — a question you can answer statically. Corollaries from the corpus:</p>
        <list ordered="false" p="615">
          <item>Make the stamp **mandatory**, not defaulted. Discord's frozen default is the cost of optional.</item>
          <item>Consider carrying the **schema itself**, not just a number, as Avro object container files do
  ("they just include the schema once at the beginning of the file"). That is the only technique
  found that fully removes the need to negotiate.</item>
        </list>
      </section>
      <section title="4.4.2 Separate &quot;stop writing&quot; from &quot;stop reading&quot; — Kubernetes&apos; distinction">
        <p p="616">This is the single most useful governance idea in the study, and it dissolves most of the
apparent tension:</p>
        <quote p="617">"no API versions that have been persisted to storage may be removed. Serving REST endpoints for
those versions may be disabled…, but the API server must remain capable of decoding/converting
previously persisted data from storage."
— https://kubernetes.io/docs/reference/using-api/deprecation-policy/ — accessed 2026-08-09</quote>
        <p p="618">Applied here: **you may stop emitting an old shape whenever you like. You may never stop being
able to read one.** That splits a single frightening decision into one cheap decision (change
what you write, today) and one permanent but small obligation (keep the reader for the old
shape). Reader code is cheap; it is a pure function with no runtime cost when unused, and it can
be covered by golden fixtures forever.</p>
      </section>
      <section title="4.4.3 Round-tripping as the real invariant">
        <p p="619">Kubernetes Rule #2 is what makes aggressive change safe:</p>
        <quote p="620">"API objects must be able to round-trip between API versions in a given release without
information loss"</quote>
        <p p="621">If old→new→old is lossless, you can restructure freely, because any consumer's view can be
reconstructed. Iceberg states the same guarantee for files and gets it from **stable field IDs**
rather than names — "Iceberg guarantees that schema evolution changes are independent and free
of side-effects, without rewriting files." Renaming becomes free once identity is an ID rather
than a name. That is worth designing in early; it cannot be retrofitted.</p>
      </section>
      <section title="4.4.4 Grow closed vocabularies by widening, never by adding in place">
        <p p="622">This is the one place where the research is genuinely restrictive, and LinkedIn published the
workaround for precisely the uncontrolled-consumer case:</p>
        <quote p="623">"one should consider making a backward compatible change (such as adding a new optional field
containing a new enum field with more symbols) and supporting the existing clients, with the
existing enum symbols, indefinitely."</quote>
        <p p="624">So the enum is not frozen — the *old field* is. New vocabulary lands in a new field; the legacy
field keeps carrying a best-effort legacy value. Cheaper still, and endorsed by Google:</p>
        <quote p="625">"For enums that change frequently, the API **should** use a string and document the format."
"enums **should** document whether the enum is frozen or they expect to add values in the future."</quote>
        <p p="626">**Declaring openness up front is free and permanently valuable.** Kubernetes says the same:
"document that expectation clearly in the API field description in the first release the field is
made available." A reader that was told on day one "this set is open" has no excuse; a reader
that was told nothing will write an exhaustive switch.</p>
      </section>
      <section title="4.4.5 Designate extension points explicitly — the RFC 6709 lesson">
        <p p="627">RFC 6709 and RFC 9413 disagree about tolerance in general but reconcile on granularity: TLS
"ignore unknown record types but… reject unknown handshake messages." Mark, in the spec, which
parts of the document are open for growth and which are closed. Readers can then be tolerant
exactly where tolerance is correct and strict everywhere else — which is also the only way to get
RFC 9413's benefit ("Tolerating unexpected input instead conceals problems") without paying its
cost.</p>
      </section>
      <section title="4.4.6 Gate it in CI — the enforcement that survives">
        <p p="628">`buf breaking`, `oasdiff`, `graphql-inspector` and Avro's `SchemaCompatibility` are all pure
schema-vs-schema diffs requiring no server and no telemetry. **This is what buys the freedom to
move fast.** Note the pattern from §1.9–1.11: LinkedIn and Uber are the two organisations that
let themselves stay versionless internally, and they are the two with a hard CI gate. The gate is
not a brake on evolution; it is the thing that makes confident evolution possible.</p>
        <p p="629">Set the level deliberately, as LinkedIn does, and keep the escape hatch honest:</p>
        <quote p="630">"You are always free to ignore backwards-incompatible change messages if you know that the change
will not cause problems, or are willing to take steps to ensure that it will not."</quote>
      </section>
      <section title="4.4.7 When you must make a genuinely breaking change">
        <p p="631">The corpus converges on: **publish both shapes for a window, then stop writing the old one.**
That is Stripe's pinning, Kubernetes' dual-version serving and Twitter's blackout tests, all
reduced to what a file publisher can actually do:</p>
        <list ordered="true" p="632">
          <item>Bump the stamp and emit the new shape alongside the old (dual-write) — the at-rest form of
   "serve both versions".</item>
          <item>Announce it in the artifact itself, since you cannot reach consumers any other way. Nobody in
   this study had to solve announcement-without-a-channel; it is the one place where a file
   publisher is strictly worse off than any API vendor, and the mitigation is to make the data
   self-announcing (a `deprecated`/`supersededBy` marker travelling in the file, as schema.org
   does with terms).</item>
          <item>Ship a converter as a first-class artifact, and keep it forever. This is the substitute for
   the server-side translation layer that does not transfer.</item>
          <item>Stop emitting the old shape. Keep reading it indefinitely (§4.4.2).</item>
        </list>
        <p p="633">**The honest limit, stated plainly:** at step 2 you have no way to know whether anyone is still
reading the old shape. Netflix removes a field "once the stats show that a deprecated field is no
longer used"; you will never have those stats. So the transition window is chosen by judgement,
not by evidence — and the reader-side obligation from §4.4.2 is what makes choosing wrong
survivable rather than fatal.</p>
        <p p="634">**The suspicion in the brief is correct, and sharper than stated.** Most of the mobile playbook
depends not merely on the server knowing its clients, but on the *client having asked a
question*. GraphQL — the single most-cited success story for versionless evolution, with Meta's
own "three years of released Facebook applications" and "nearly 1,000 shipped application
versions" as evidence — achieves it through a mechanism a file fundamentally cannot have. Meta
proves the opposite point too, and proves it against itself: **the same company that runs a
versionless internal API runs an explicitly versioned external one with a hard two-year clock**,
and the only difference is whether it controls the consumer. When Meta faces third-party
consumers it does not control, Meta versions.</p>
        <p p="635">The practices that do survive are not the famous ones. They are the boring static disciplines —
never remove, never reuse, stamp the artifact, gate the diff in CI, and do not build closed
vocabularies you intend to grow. Notably, every format actually designed for data at rest
converged on these independently: Avro embeds the writer's schema, Iceberg uses field IDs and
has no enum type at all, npm puts an integer version at the top of the file, and schema.org
never deletes a term.</p>
      </section>
    </section>
  </section>
  <section title="§5 RE-FETCH LIST">
    <p p="636">**Every URL below was accessed 2026-08-09.** Labels: **P** = primary (the organisation's own
docs, blog, spec, or source code) · **S** = secondary (third-party blog or summary; weak) ·
★ = load-bearing for a headline claim · ⚠ = reliability caveat, re-verify before publishing.</p>
    <section title="5.1 Meta / Facebook · Thrift">
      <table p="637">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>1</td>
          <td>https://engineering.fb.com/2015/09/14/core-infra/graphql-a-data-query-language/</td>
          <td>**P ★** (double-fetched)</td>
        </tr>
        <tr>
          <td>2</td>
          <td>https://thrift.apache.org/static/files/thrift-20070401.pdf</td>
          <td>**P ★** (PDF; text extracted locally with pypdf)</td>
        </tr>
        <tr>
          <td>3</td>
          <td>https://developers.facebook.com/docs/graph-api/guides/versioning/</td>
          <td>**P ★** (double-fetched)</td>
        </tr>
        <tr>
          <td>4</td>
          <td>https://developers.facebook.com/docs/graph-api/changelog/breaking-changes/</td>
          <td>**P** — index only, no definitions</td>
        </tr>
        <tr>
          <td>5</td>
          <td>https://github.com/microsoft/thrifty/issues/84</td>
          <td>**P** (issue tracker)</td>
        </tr>
        <tr>
          <td>6</td>
          <td>https://www.mail-archive.com/dev@thrift.apache.org/msg50731.html</td>
          <td>**P** (THRIFT-5392)</td>
        </tr>
        <tr>
          <td>7</td>
          <td>https://raw.githubusercontent.com/reactiflux/q-and-a/master/lee-byron_facebook-graphql.md</td>
          <td>**P** — contains nothing on versioning</td>
        </tr>
        <tr>
          <td>8</td>
          <td>https://github.com/facebook/facebook-android-sdk/pull/544</td>
          <td>**P ★** (real crash + stack trace)</td>
        </tr>
      </table>
    </section>
    <section title="5.2 GraphQL as a movement">
      <table p="638">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>9</td>
          <td>https://raw.githubusercontent.com/graphql/graphql.github.io/source/src/pages/faq/best-practices.mdx</td>
          <td>**P** — graphql.org itself 403s</td>
        </tr>
        <tr>
          <td>10</td>
          <td>https://raw.githubusercontent.com/graphql/graphql.github.io/source/src/pages/learn/schema-design.mdx</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>11</td>
          <td>http://chentsulin.github.io/graphql.github.io/learn/best-practices/</td>
          <td>**S** — mirror, used only to corroborate #10</td>
        </tr>
        <tr>
          <td>12</td>
          <td>https://web.archive.org/web/20230601000000/https://graphql.org/learn/best-practices/</td>
          <td>**P ★** (archived; live page dropped the Versioning section)</td>
        </tr>
        <tr>
          <td>13</td>
          <td>https://graphql.org/learn/governance-versioning/</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>14</td>
          <td>https://raw.githubusercontent.com/graphql/graphql-spec/main/spec/Section%203%20--%20Type%20System.md</td>
          <td>**P ★** (`@deprecated`, enum coercion)</td>
        </tr>
        <tr>
          <td>15</td>
          <td>https://raw.githubusercontent.com/graphql/graphql-js/16.x.x/src/utilities/findBreakingChanges.ts</td>
          <td>**P ★** (`DangerousChangeType`)</td>
        </tr>
        <tr>
          <td>16</td>
          <td>https://raw.githubusercontent.com/graphql-hive/graphql-inspector/master/packages/core/src/diff/changes/enum.ts</td>
          <td>**P ★** ("programming defensively")</td>
        </tr>
        <tr>
          <td>17</td>
          <td>https://docs.github.com/en/graphql/overview/breaking-changes</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>18</td>
          <td>https://github.com/graphql/graphql-spec/issues/175 · /issues/134</td>
          <td>**P** — #134's comment thread did not render</td>
        </tr>
        <tr>
          <td>19</td>
          <td>https://charpeni.com/blog/graphql-enums-are-unsafe</td>
          <td>**S**</td>
        </tr>
        <tr>
          <td>20</td>
          <td>https://productionreadygraphql.com/blog/2019-11-06-how-should-we-version-graphql-apis/</td>
          <td>**S ⚠** — fetch truncated; **no claim rests on it**</td>
        </tr>
        <tr>
          <td>21</td>
          <td>https://raw.githubusercontent.com/apollographql/apollo-kotlin/main/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo/compiler/codegen/kotlin/schema/EnumAsEnumBuilder.kt</td>
          <td>**P ★** (`UNKNOWN__`)</td>
        </tr>
        <tr>
          <td>22</td>
          <td>https://github.com/apollographql/apollo-kotlin/issues/6243</td>
          <td>**P** — no maintainer rationale present</td>
        </tr>
      </table>
    </section>
    <section title="5.3 Google">
      <table p="639">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>23</td>
          <td>https://google.aip.dev/180 (raw: `github.com/aip-dev/google.aip.dev/…/0180.md`)</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>24</td>
          <td>https://google.aip.dev/181 · /185</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>25</td>
          <td>https://google.aip.dev/126</td>
          <td>**P ★** (enum budget) — retrieved in full</td>
        </tr>
        <tr>
          <td>26</td>
          <td>https://google.aip.dev/216</td>
          <td>**P ★** (the candid enum paragraph)</td>
        </tr>
        <tr>
          <td>27</td>
          <td>https://protobuf.dev/programming-guides/enum/</td>
          <td>**P ★** (open vs closed)</td>
        </tr>
        <tr>
          <td>28</td>
          <td>https://protobuf.dev/programming-guides/proto3/ · /proto2/</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>29</td>
          <td>https://protobuf.dev/best-practices/dos-donts/</td>
          <td>**P ★** (note: `/programming-guides/dos-donts/` redirects here)</td>
        </tr>
        <tr>
          <td>30</td>
          <td>https://protobuf.dev/programming-guides/json/</td>
          <td>**P** (ProtoJSON)</td>
        </tr>
        <tr>
          <td>31</td>
          <td>https://github.com/protocolbuffers/protobuf/issues/7392</td>
          <td>**P ★** (JSON unknown-enum spec gap)</td>
        </tr>
        <tr>
          <td>32</td>
          <td>https://github.com/protocolbuffers/protobuf/issues/16857</td>
          <td>**P ★** (Google broke its own client)</td>
        </tr>
        <tr>
          <td>33</td>
          <td>https://cloud.google.com/terms/ §1.4(e)</td>
          <td>**P ★** (double-fetched; 12 months)</td>
        </tr>
        <tr>
          <td>34</td>
          <td>https://cloud.google.com/terms/deprecation</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>35</td>
          <td>https://developer.android.com/guide/playcore/in-app-updates · /kotlin-java</td>
          <td>**P ★** (Q4)</td>
        </tr>
        <tr>
          <td>36</td>
          <td>https://linter.aip.dev/ · https://api.github.com/repos/googleapis/api-linter/contents/rules</td>
          <td>**P ★** (proves `aip0180` absent)</td>
        </tr>
        <tr>
          <td>37</td>
          <td>https://github.com/googleapis/proto-breaking-change-detector</td>
          <td>**P** ("not an officially supported Google project")</td>
        </tr>
        <tr>
          <td>—</td>
          <td>**Redirects, do not cite separately:** `cloud.google.com/apis/design/compatibility` → 301 → aip.dev/180; `…/design/versioning` → 301 → aip.dev/185</td>
          <td></td>
        </tr>
      </table>
    </section>
    <section title="5.4 Twitter / X">
      <p p="640">**Fetch note:** `blog.x.com` / `blog.twitter.com` → **HTTP 403** (Cloudflare JS challenge) to
WebFetch *and* curl-with-browser-UA. `developer.x.com` → **HTTP 402**. `web.archive.org` is
blocked to WebFetch but reachable via curl. Use Wayback raw (`…id_/`) snapshots.</p>
      <table p="641">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>38</td>
          <td>`https://web.archive.org/web/20200924190056id_/https://developer.twitter.com/en/docs/twitter-api/versioning`</td>
          <td>**P ★** (v2 policy + durations)</td>
        </tr>
        <tr>
          <td>39</td>
          <td>`https://web.archive.org/web/20240416135831id_/https://blog.twitter.com/developer/en_us/a/2012/changes-coming-to-twitter-api`</td>
          <td>**P ★** (six months)</td>
        </tr>
        <tr>
          <td>40</td>
          <td>`https://web.archive.org/web/20231205015116id_/…/current-status-api-v1-1`</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>41</td>
          <td>`https://web.archive.org/web/20240226063445id_/…/api-v1-retirement-final-dates`</td>
          <td>**P ★** (`HTTP 410 Gone`)</td>
        </tr>
        <tr>
          <td>42</td>
          <td>`https://web.archive.org/web/20231211182010id_/…/api-v1-is-retired`</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>43</td>
          <td>`https://web.archive.org/web/20240303153815id_/…/using-fields-and-expansions`</td>
          <td>**P** (the weak rationale)</td>
        </tr>
        <tr>
          <td>44</td>
          <td>`https://web.archive.org/web/20220707142143id_/…/migrate/whats-new` · `…/early-access`</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>45</td>
          <td>https://docs.x.com/x-api/fundamentals/fields · /expansions · /x-ads-api/fundamentals/versioning</td>
          <td>**P** (live)</td>
        </tr>
        <tr>
          <td>46</td>
          <td>https://raw.githubusercontent.com/twitter/scrooge/develop/scrooge-core/src/main/scala/com/twitter/scrooge/ThriftEnum.scala</td>
          <td>**P ★** (`apply` throws)</td>
        </tr>
        <tr>
          <td>47</td>
          <td>https://raw.githubusercontent.com/twitter/scrooge/develop/CHANGELOG.rst</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>48</td>
          <td>https://groups.google.com/g/twitter-development-talk/c/ahbvo3VTIYI</td>
          <td>**P** (Snowflake `id_str`)</td>
        </tr>
        <tr>
          <td>—</td>
          <td>**NOT RETRIEVABLE:** "Introducing a new and improved Twitter API" (July 2020) — Wayback holds only 301s for every URL variant; live 403. **No verbatim text obtained; none paraphrased.**</td>
          <td></td>
        </tr>
      </table>
    </section>
    <section title="5.5 Badoo / Bumble">
      <table p="642">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>49</td>
          <td>https://raw.githubusercontent.com/badoo/techblog/master/_posts/2016-05-04-crazy-agile-api.markdown</td>
          <td>**P ★** (authored source)</td>
        </tr>
        <tr>
          <td>50</td>
          <td>https://medium.com/bumble-tech/crazy-agile-api-5130be6f5b06</td>
          <td>**P** (rendered form of #49)</td>
        </tr>
        <tr>
          <td>51</td>
          <td>https://www.slideshare.net/BadooDev/versioning-strategy-for-a-complex-internal-api</td>
          <td>**P ★★** (the unknown-banner slides)</td>
        </tr>
        <tr>
          <td>52</td>
          <td>https://habr.com/ru/company/badoo/blog/305888/</td>
          <td>**P** (Russian original; extra clause)</td>
        </tr>
        <tr>
          <td>53</td>
          <td>https://api.github.com/repos/badoo/techblog/contents/_posts?ref=master</td>
          <td>**P** (post index)</td>
        </tr>
        <tr>
          <td>54</td>
          <td>https://nordicapis.com/continuous-versioning-strategy-for-internal-apis/</td>
          <td>**S ⚠** — its "never had a breaking change" claim is **unverified; do not cite as Badoo's words**</td>
        </tr>
        <tr>
          <td>—</td>
          <td>**Dead:** `tech.badoo.com/*` → 301 → `badoo.com/` (dead); `badootech.badoo.com/*` → 301 → `medium.com/bumble-tech`. The 2014 JSConf EU post source is a 674-byte stub with no prose.</td>
          <td></td>
        </tr>
      </table>
    </section>
    <section title="5.6 LinkedIn · Netflix · Uber · Airbnb">
      <table p="643">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>55</td>
          <td>https://linkedin.github.io/rest.li/modeling/compatibility_check</td>
          <td>**P ★★** (double-fetched; the enum essay)</td>
        </tr>
        <tr>
          <td>56</td>
          <td>https://linkedin.github.io/rest.li/setup/gradle · /Rest_li-2_x-upgrade-instructions · /spec/protocol</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>57</td>
          <td>`raw.githubusercontent.com/linkedin/rest.li/master/data/src/main/java/com/linkedin/data/schema/compatibility/CompatibilityChecker.java`</td>
          <td>**P ★** (the real rules)</td>
        </tr>
        <tr>
          <td>58</td>
          <td>`…/compatibility/CompatibilityMessage.java` · `…/idlcheck/CompatibilityLevel.java` · `…/idlcheck/CompatibilityInfo.java`</td>
          <td>**P ★** (`WIRE_COMPATIBLE`) ⚠ runtime wiring unverified</td>
        </tr>
        <tr>
          <td>59</td>
          <td>`…/data/template/DataTemplateUtil.java`</td>
          <td>**P ★** (`$UNKNOWN`)</td>
        </tr>
        <tr>
          <td>60</td>
          <td>https://learn.microsoft.com/en-us/linkedin/marketing/versioning</td>
          <td>**P ★** (1-year minimum)</td>
        </tr>
        <tr>
          <td>61</td>
          <td>https://www.linkedin.com/blog/engineering/marketing/under-the-hood-how-we-built-api-versioning-for-linkedin-market</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>62</td>
          <td>https://netflixtechblog.com/how-netflix-scales-its-api-with-graphql-federation-part-2-bbe71aaec44a</td>
          <td>**P ★** (double-fetched; usage-gated deprecation)</td>
        </tr>
        <tr>
          <td>63</td>
          <td>https://netflixtechblog.com/embracing-the-differences-inside-the-netflix-api-redesign-15fd8b3dc49d</td>
          <td>**P** (800 device types)</td>
        </tr>
        <tr>
          <td>64</td>
          <td>https://netflixtechblog.com/practical-api-design-at-netflix-part-1-using-protobuf-fieldmask-35cfdc606518 · /safe-updates-of-client-applications-at-netflix-1d01c71a930c</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>65</td>
          <td>https://raw.githubusercontent.com/Netflix/dgs-framework/master/graphql-dgs-client/src/main/kotlin/com/netflix/graphql/dgs/client/GraphQLResponse.kt</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>66</td>
          <td>https://help.netflix.com/en/node/112425 · /119807 · /295469825389156</td>
          <td>**P ★** (Q4)</td>
        </tr>
        <tr>
          <td>67</td>
          <td>https://www.uber.com/blog/architecture-api-gateway/</td>
          <td>**P ★★** (the CI-gate quote)</td>
        </tr>
        <tr>
          <td>68</td>
          <td>https://raw.githubusercontent.com/uber/prototool/dev/style/README.md · /docs/breaking.md</td>
          <td>**P ★** ⚠ repo archived 2026-03-04</td>
        </tr>
        <tr>
          <td>69</td>
          <td>https://raw.githubusercontent.com/uber/idl/master/README.md</td>
          <td>**P ★** ("one version of the world")</td>
        </tr>
        <tr>
          <td>70</td>
          <td>https://raw.githubusercontent.com/uber/zanzibar/master/docs/thrift.md · `thriftrw-go/dev/gen/enum.go`</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>71</td>
          <td>https://bitrise.io/blog/post/q-and-a-on-building-apps-at-scale-part-1</td>
          <td>**S ⚠** (page states answers were "edited down and summarized")</td>
        </tr>
        <tr>
          <td>72</td>
          <td>https://medium.com/airbnb-engineering/a-deep-dive-into-airbnbs-server-driven-ui-system-842244c5f5</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>73</td>
          <td>https://github.com/MobileNativeFoundation/discussions/discussions/47</td>
          <td>**P ★** (richest Airbnb source)</td>
        </tr>
        <tr>
          <td>74</td>
          <td>https://medium.com/airbnb-engineering/building-services-at-airbnb-part-4-23c95e428064</td>
          <td>**P ★** (their CI gate)</td>
        </tr>
        <tr>
          <td>75</td>
          <td>https://www.airbnb.com/help/article/3418</td>
          <td>**P** (6-month partner clause)</td>
        </tr>
        <tr>
          <td>—</td>
          <td>**BLOCKED:** `developer.withairbnb.com/docs/homes/versioning` → 302 → login wall. Highest-value unresolved target.</td>
          <td></td>
        </tr>
        <tr>
          <td>—</td>
          <td>**Fetch techniques:** `netflixtechblog.com` 307s twice through `medium.com/m/global-identity-2` — follow both hops. Medium mangles under WebFetch; prefix `https://r.jina.ai/` for clean markdown + exact-phrase checks.</td>
          <td></td>
        </tr>
      </table>
    </section>
    <section title="5.7 Compatibility-checking tools and formats">
      <table p="644">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>76</td>
          <td>https://buf.build/docs/breaking/ · /rules/ · /usage/ · /bsr/checks/breaking/</td>
          <td>**P ★** (note `/breaking/overview` redirects)</td>
        </tr>
        <tr>
          <td>77</td>
          <td>https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html</td>
          <td>**P ⚠ RE-FETCH BY HAND** — per-format tables were **inconsistent across two fetches**; definitions + upgrade-ordering block were stable</td>
        </tr>
        <tr>
          <td>78</td>
          <td>https://docs.confluent.io/cloud/current/sr/fundamentals/schema-evolution.html</td>
          <td>**P** — the word "enum" does not appear</td>
        </tr>
        <tr>
          <td>79</td>
          <td>https://docs.confluent.io/platform/current/schema-registry/develop/maven-plugin.html</td>
          <td>**P ★** (local vs server goals)</td>
        </tr>
        <tr>
          <td>80</td>
          <td>https://github.com/confluentinc/schema-registry/issues/601</td>
          <td>**P ★** (Confluent engineer: enum addition *is* forward-incompatible)</td>
        </tr>
        <tr>
          <td>81</td>
          <td>https://avro.apache.org/docs/1.12.0/specification/ (identical at 1.11.1)</td>
          <td>**P ★★** (enum `default`, "an error is signalled", object container files)</td>
        </tr>
        <tr>
          <td>82</td>
          <td>`raw.githubusercontent.com/apache/avro/main/lang/java/avro/src/main/java/org/apache/avro/SchemaCompatibility.java`</td>
          <td>**P ★** (`MISSING_ENUM_SYMBOLS`)</td>
        </tr>
        <tr>
          <td>83</td>
          <td>https://issues.apache.org/jira/browse/AVRO-1340</td>
          <td>**P** (ASF project record)</td>
        </tr>
        <tr>
          <td>84</td>
          <td>https://json-schema.org/understanding-json-schema/reference/enum · /blog/posts/future-of-json-schema</td>
          <td>**P** — **no evolution model exists**</td>
        </tr>
        <tr>
          <td>85</td>
          <td>https://www.apollographql.com/docs/graphos/platform/schema-management/checks · /checks/run · /checks/reference</td>
          <td>**P ★★** ("no operation metrics… result in a failed check")</td>
        </tr>
        <tr>
          <td>86</td>
          <td>https://the-guild.dev/graphql/inspector/docs/commands/diff · /essentials/diff</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>87</td>
          <td>https://github.com/oasdiff/oasdiff · https://www.oasdiff.com/docs/breaking-changes</td>
          <td>**P ★** (per-position enum checks)</td>
        </tr>
        <tr>
          <td>88</td>
          <td>https://raw.githubusercontent.com/apache/iceberg/main/docs/docs/evolution.md</td>
          <td>**P ★** (metadata-only evolution)</td>
        </tr>
        <tr>
          <td>89</td>
          <td>Jackson `DeserializationFeature` javadoc · kotlinx `coerceInputValues` · Moshi `EnumJsonAdapter.kt` · kotlinx issues #3071, #1303</td>
          <td>**P ★** (defaults throw)</td>
        </tr>
        <tr>
          <td>90</td>
          <td>`raw.githubusercontent.com/swiftlang/swift-evolution/main/proposals/0192-non-exhaustive-enums.md`</td>
          <td>**P ★**</td>
        </tr>
        <tr>
          <td>91</td>
          <td>https://github.com/joelittlejohn/jsonschema2pojo/issues/728 · https://github.com/swagger-api/swagger-codegen/issues/7304</td>
          <td>**P ★**</td>
        </tr>
      </table>
    </section>
    <section title="5.8 Governance corpora, standards, and data-at-rest precedents">
      <table p="645">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>92</td>
          <td>`raw.githubusercontent.com/kubernetes/community/main/contributors/devel/sig-architecture/api_changes.md` (also `master`)</td>
          <td>**P ★★** (double-fetched; the enum ruling)</td>
        </tr>
        <tr>
          <td>93</td>
          <td>https://kubernetes.io/docs/reference/using-api/deprecation-policy/</td>
          <td>**P ★★** (double-fetched; Rules #1/#2/#4a, the storage clause)</td>
        </tr>
        <tr>
          <td>94</td>
          <td>https://stripe.com/blog/api-versioning · https://docs.stripe.com/upgrades</td>
          <td>**P ★** (pinning; the compatible-changes list)</td>
        </tr>
        <tr>
          <td>95</td>
          <td>https://www.rfc-editor.org/rfc/rfc9413.txt (and .html)</td>
          <td>**P ★** (abstract + §5.1 verified verbatim)</td>
        </tr>
        <tr>
          <td>96</td>
          <td>https://www.rfc-editor.org/rfc/rfc6709.txt (and .html)</td>
          <td>**P ★** (MBZ, §4.7, §A.3 TLS)</td>
        </tr>
        <tr>
          <td>97</td>
          <td>https://openid.net/specs/openid-connect-core-1_0.html</td>
          <td>**P ★** (MUST-ignore vs MAY-error asymmetry)</td>
        </tr>
        <tr>
          <td>98</td>
          <td>https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json</td>
          <td>**P ★★** (`lockfileVersion` — closest analogue)</td>
        </tr>
        <tr>
          <td>99</td>
          <td>https://schema.org/docs/howwework.html</td>
          <td>**P ★★** (double-fetched; never delete an enumerated value)</td>
        </tr>
        <tr>
          <td>100</td>
          <td>https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html</td>
          <td>**S** (strong — expert-authored, not an org's position)</td>
        </tr>
        <tr>
          <td>101</td>
          <td>https://docs.discord.com/developers/reference</td>
          <td>**P ★★** (default frozen on a deprecated version)</td>
        </tr>
        <tr>
          <td>102</td>
          <td>https://slack.com/help/articles/1500001836081-Slacks-deprecation-schedule · https://support.signal.org/hc/en-us/articles/5109141421850-Supporting-Older-Operating-Systems</td>
          <td>**P ★** (Q4 as routine)</td>
        </tr>
      </table>
    </section>
    <section title="5.9 Post-mortems">
      <table p="646">
        <tr>
          <td>#</td>
          <td>URL</td>
          <td>Label</td>
        </tr>
        <tr>
          <td>103</td>
          <td>https://blog.cloudflare.com/18-november-2025-outage/</td>
          <td>**P ★★** (closest analogue to data-at-rest)</td>
        </tr>
        <tr>
          <td>104</td>
          <td>https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW</td>
          <td>**P ★★**</td>
        </tr>
        <tr>
          <td>105</td>
          <td>https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/</td>
          <td>**P ★** (config bypasses staged rollout)</td>
        </tr>
        <tr>
          <td>106</td>
          <td>https://www.fastly.com/blog/summary-of-june-8-outage</td>
          <td>**P ★** ("valid" config)</td>
        </tr>
        <tr>
          <td>107</td>
          <td>https://web.archive.org/web/20230129150309/https://api.slack.com/changelog/2021-02-24-how-we-broke-your-slack-app</td>
          <td>**P ★** (live page is an SPA shell)</td>
        </tr>
        <tr>
          <td>108</td>
          <td>https://about.roblox.com/newsroom/2022/01/roblox-return-to-service-10-28-10-31-2021</td>
          <td>**P**</td>
        </tr>
        <tr>
          <td>109</td>
          <td>https://steamcommunity.com/discussions/forum/14/2974028351344359625/</td>
          <td>**P ★** (adoption-gated protocol change)</td>
        </tr>
        <tr>
          <td>110</td>
          <td>https://github.com/bitnami/charts/issues/7264 · https://github.com/Azure/AKS/issues/1205 · https://cert-manager.io/docs/releases/upgrading/ingress-class-compatibility/</td>
          <td>**P** (K8s removal breaking deployed controllers)</td>
        </tr>
        <tr>
          <td>111</td>
          <td>https://livefront.com/writing/dont-live-with-regret-build-a-kill-switch-into-your-mobile-app/</td>
          <td>**S** (consultancy, not an operator)</td>
        </tr>
      </table>
    </section>
    <section title="5.10 Explicit NOT-FOUND register">
      <p p="647">Recorded so the gaps are not silently re-filled later:</p>
      <list ordered="true" p="648">
        <item>**Meta Q3/Q4 for the Graph API** — no definition of a breaking change, no enum guidance, no
   force-upgrade statement. Searched: the versioning guide, the breaking-changes changelog index,
   engineering.fb.com, the Lee Byron reactiflux transcript.</item>
        <item>**A Lee Byron (or spec-editor) verbatim defence of no-versioning** — spec issues #175/#134 link
   to it without reproducing it; the comment thread did not render.</item>
        <item>**Twitter on enum growth** — absent from *both* the breaking and non-breaking lists.</item>
        <item>**Twitter's stated rationale for fields/expansions as an evolution mechanism** — only
   "simplicity"/"use case". The v2 announcement post is unretrievable.</item>
        <item>**Badoo on unknown-enum *recovery*** (as opposed to prevention), any deprecation duration, or
   any automated CI gate.</item>
        <item>**Netflix on enum growth**, and any Netflix *engineering* statement that devices cannot be
   updated (only consumer-facing help pages show eviction).</item>
        <item>**Uber Q4 and Q5** — no primary statement on forced upgrade or deprecation duration.</item>
        <item>**Airbnb Q3, Q4, Q5 (mobile)** — "unknown" and "deprecat" are absent from the SDUI article
   entirely. Third-party claims that Airbnb "returns a fallback component" have **no Airbnb
   source; do not repeat them.**</item>
        <item>**Confluent on enum add/remove per format** — the word "enum" does not appear on either
   schema-evolution page; the gap is closed only by issue #601.</item>
        <item>**Any normative JSON Schema rule on enum evolution** — none exists; JSON Schema has no
    writer-schema/reader-schema concept at all.</item>
        <item>**Google-published CI gate for AIP-180** — `aip0180` is absent from the linter's rule tree.</item>
        <item>**Apollo Kotlin's stated rationale for `UNKNOWN__`** — the behaviour is source-verified; the
    reasoning in a maintainer's words is not.</item>
        <item>**WhatsApp's official supported-versions policy** — the FAQ URL tried returned 404 and only
    secondary tech-news coverage was found; **not pursued further and not used.**</item>
      </list>
    </section>
  </section>
</spec>
