From 97396a67881c8518d141c7ab4c991b40ce8f022c Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Sun, 13 Sep 2026 16:53:15 -0400 Subject: [PATCH 01/12] Align lifecycle.md with SAF framing using discussion #5 example data Restructures the Lifecycle Object around the operation-scoped deprecation/sunset model from discussion #5's April 2026+ comments (the root-level, document-wide draft from earlier in the thread is superseded) and places it under `features` on the Operation Object, consistent with the Standardized API Feature (SAF) concept being defined in OAI/OpenAPI-Specification#5310 and #5350. Fills in the Introduction, Relationship to the OpenAPI Specification, Definitions (Lifecycle Object, Sunset Object), a new informative Header Mapping section (field -> Deprecation/Sunset/Link header mapping per RFC 9745, RFC 8594, RFC 8288), Appendix A revision history, and a new informative Appendix B on AI agent/MCP tooling usage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YAmP3nFaRwCARS7JygrV7r --- src/lifecycle.md | 205 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 9 deletions(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 1829328..68025df 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -8,19 +8,75 @@ ## Introduction -TODO: Introduce the extension and what problem it solves. +OpenAPI's only built-in signal for the end of an operation's life is the +`deprecated` boolean on the Operation Object. It says an operation is going +away, but nothing about *when*, *why*, where to migrate to, or what replaces +it. HTTP already has standard, structured ways to communicate this at +runtime — the `Deprecation` header ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) +and the `Sunset` header ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) — +but there is no standard, design-time way to describe that information in an +OpenAPI description, so providers fall back to prose in `info.description`, +one-off `x-` extensions, or per-operation custom headers, none of which are +interoperable or reliably machine-readable. + +This matters more as OpenAPI descriptions feed more than human-facing +documentation. Doc-gen and SDK-gen tools need a stable place to source +`@deprecated` annotations and migration links. AI agents and MCP servers that +register OpenAPI operations as tools need a design-time signal they can fold +into a tool's description *before* it is ever invoked, and a runtime signal +(from response headers) they can act on *after* invocation. A structured +Lifecycle Object serves both without requiring either kind of consumer to +parse prose. + +This specification defines that object and a deterministic mapping from its +fields to the `Deprecation`, `Sunset`, and `Link` headers, so tooling can treat +the metadata as either documentation or a generation source for those +headers, per its needs. + +**Scope.** The Lifecycle Object applies at the Operation Object level only. An +earlier draft explored a root-level, document-wide object (see +[Appendix A](#appendix-a--revision-history)), but SIG discussion concluded +that a single OpenAPI document does not necessarily describe exactly one API, +so a document-wide deprecation signal does not generalize; see +[sig-lifecycle discussion #5](https://github.com/OAI/sig-lifecycle/discussions/5). +Applying the same lifecycle metadata uniformly across many operations remains +the job of [Overlays](https://spec.openapis.org/overlay/latest.html), not of +this object. + +**Non-goal.** This specification does not replace `deprecated: true`, which +remains the coarse signal tooling already recognizes (for example, to gray out +an operation in Swagger UI). The Lifecycle Object is additional detail used +alongside it. ## Relationship to the OpenAPI Specification The Lifecycle Specification defines the **Lifecycle Object**, which appears -within an OpenAPI document under the `features` field introduced in OpenAPI 3.3: +under the `features` field introduced in OpenAPI 3.3 as a Standardized API +Feature (SAF) — see +[OAI/OpenAPI-Specification discussion #5310](https://github.com/OAI/OpenAPI-Specification/discussions/5310) +for background on the SAF concept, and +[discussion #5350](https://github.com/OAI/OpenAPI-Specification/discussions/5350) +for how SAFs are expected to declare their version within a document. Because +the Lifecycle Object is scoped to individual operations (see Introduction), it +is placed under `features` on the Operation Object: ```yaml -features: - lifecycle: - # Lifecycle Object fields here +paths: + /pets: + get: + deprecated: true + features: + lifecycle: + # Lifecycle Object fields here ``` +As proposed for SAFs generally, a document using this feature is expected to +declare which version of it is in use (for example, via a document-scoped +`usingFeatures` or `usingFeatureGroups` field), independently of the +`openapi` version itself. That mechanism is still being defined by the SAF +work referenced above; this specification will adopt it once it lands rather +than defining a competing one. + Compatibility between versions of this specification and versions of the OpenAPI Specification is determined by publication date. The rules for determining compatible version pairs are documented in [TODO: link or section]. @@ -29,13 +85,63 @@ compatible version pairs are documented in [TODO: link or section]. ### Lifecycle Object -A **Lifecycle Object** is ... +A **Lifecycle Object** describes the deprecation and sunset status of a +single operation: when the deprecation took effect, why, where to find a +migration guide and a replacement, and when the operation will stop accepting +requests. It is optional, and is meant to complement — not replace — +`deprecated: true` on the same Operation Object. + +#### Lifecycle Object Fixed Fields + +| Field Name | Type | Description | +| ---- | ---- | ---- | +| `date` | `string` (date-time) | **REQUIRED.** The date the deprecation takes or took effect. | +| `reason` | `string` | A human-readable explanation of why the operation is deprecated. | +| `migration` | [External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object) | A description of, and link to, a migration guide. | +| `replacement` | [External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object) | A description of, and link to, the operation or API that replaces this one. | +| `sunset` | [Sunset Object](#sunset-object) | Sunset details for this operation. | + +`migration` and `replacement` reuse the OpenAPI Specification's existing +External Documentation Object rather than introducing new object types, since +both are fundamentally a described link — the same shape already used +throughout the OAS for this purpose. + +#### Example + +```yaml +paths: + /v1/pets: + get: + operationId: listPets_v1 + summary: List all pets + deprecated: true + features: + lifecycle: + date: "2025-01-15T00:00:00Z" + reason: "Replaced by v2 API with improved performance." + migration: + description: Migration guide + url: https://docs.example.com/migrate + replacement: + description: Pets API v2 + url: https://api.example.com/v2/openapi.yaml + sunset: + date: "2025-07-01T00:00:00Z" + policy: + url: https://docs.example.com/sunset-policy +``` + +### Sunset Object + +A **Sunset Object** describes when an operation is expected to stop accepting +requests entirely. -#### Fixed Fields +#### Sunset Object Fixed Fields | Field Name | Type | Description | | ---- | ---- | ---- | -| TODO | TODO | TODO | +| `date` | `string` (date-time) | **REQUIRED.** The date after which the operation is expected to stop accepting requests. | +| `policy` | [External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object) | A link to a human-readable sunset policy. | ### Schema Object @@ -65,6 +171,87 @@ schema in effect is determined by the OAS dialect declared in that document. Standalone validation of Lifecycle Objects uses the standard JSON Schema 2020-12 dialect for Schema Object positions. +### Header Mapping + +This section is **informative**. The Lifecycle Object describes deprecation +and sunset *behavior*; the HTTP headers below are a byproduct a server MAY +choose to generate from that description at runtime. An OpenAPI description +alone does not cause a server to emit these headers — that requires runtime +support in the API implementation or gateway. + +Tools that do generate headers from a Lifecycle Object SHOULD use the +following mapping: + +| Lifecycle Object field | Generated header | RFC | +| ---- | ---- | ---- | +| `date` | `Deprecation: @` | [RFC 9745 §2.1](https://www.rfc-editor.org/rfc/rfc9745#section-2.1), timestamp format per [RFC 9651 §3.3.7](https://www.rfc-editor.org/rfc/rfc9651#section-3.3.7) | +| `migration.url` | `Link: ; rel="deprecation"; type="text/html"` | [RFC 9745 §3.1](https://www.rfc-editor.org/rfc/rfc9745#section-3.1) + [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | +| `replacement.url` | `Link: ; rel="successor-version"` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | +| `sunset.date` | `Sunset: ` | [RFC 8594 §3](https://www.rfc-editor.org/rfc/rfc8594#section-3), date format per [RFC 9110 §5.6.7](https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7) | +| `sunset.policy.url` | `Link: ; rel="sunset"` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | + +`reason`, `migration.description`, and `replacement.description` have no +header equivalent — they are documentation-only fields, intended for +doc-gen and SDK-gen tools (for example, as the body of a generated +`@deprecated` annotation). + +Applied to the [example](#example) above, a compliant server would emit: + +```http +Deprecation: @1736899200 +Sunset: Tue, 01 Jul 2025 00:00:00 GMT +Link: ; rel="deprecation"; type="text/html" +Link: ; rel="successor-version" +Link: ; rel="sunset" +``` + ## Appendix A — Revision History -TODO: Document notable changes between versions. +Prior to April 2026, drafts of this specification explored a root-level, +document-wide `lifecycle` object with `stage`/`stageDate` fields and +`deprecation`/`sunset` as independent siblings, intended to describe the +lifecycle of an entire API in one place. SIG discussion raised two problems +with that shape: + +* A single OpenAPI document does not necessarily describe exactly one API, so + there is no reliable document-wide "this API" for such an object to + describe. +* A global object competes with [Overlays](https://spec.openapis.org/overlay/latest.html) + as the SIG's established answer for applying the same metadata across many + operations, without the flexibility of choosing which operations it + applies to. + +From April 2026 onward, the design shifted to the operation-scoped model in +this document: `deprecation` (renamed `Lifecycle Object` under `features` per +the emerging [SAF](https://github.com/OAI/OpenAPI-Specification/discussions/5310) +convention) is the primary object, with `sunset` nested inside it, and +`gracePeriod` was dropped as derivable from `deprecation.date` and +`sunset.date` together. See +[sig-lifecycle discussion #5](https://github.com/OAI/sig-lifecycle/discussions/5) +for the full history. + +## Appendix B — Usage with AI Agents and MCP Tooling + +This section is **informative**. + +Servers that expose OpenAPI operations as tools (for example, MCP servers) +can use the Lifecycle Object in two distinct phases: + +1. **Tool registration (design-time, from the spec).** When registering an + operation as a tool, a server can fold `reason`, and the `migration` and + `replacement` links, into the tool's description — for example, prefixing + it with `"⚠ DEPRECATED — sunset 2025-07-01. Use listPets_v2 instead."` A + well-behaved LLM client that reads this before deciding whether to invoke + the tool should prefer the replacement from the outset, without ever + calling the deprecated one. +2. **Tool invocation (runtime, from response headers).** If the tool is + invoked anyway, a server acting as a proxy can inspect the `Deprecation`, + `Sunset`, and `Link` response headers (see [Header Mapping](#header-mapping)) + and wrap the result with a structured warning naming the sunset deadline + and the successor tool, giving the LLM what it needs to relay the warning + or retry with the replacement. + +Naming the `replacement` link and its `description` clearly (in language an +LLM would recognize as migration- or deprecation-related) is what makes phase +1 effective — the mechanism is ordinary metadata, but the wording carries the +signal. From 2438af1dbfa6ed82775b96e3004b926c6bbe3379 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Sun, 13 Sep 2026 18:26:58 -0400 Subject: [PATCH 02/12] Update lifecycle.md minor changes --- src/lifecycle.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 68025df..b92f438 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -43,7 +43,7 @@ Applying the same lifecycle metadata uniformly across many operations remains the job of [Overlays](https://spec.openapis.org/overlay/latest.html), not of this object. -**Non-goal.** This specification does not replace `deprecated: true`, which +**Non-goal.** This SAF does not replace `deprecated: true`, which remains the coarse signal tooling already recognizes (for example, to gray out an operation in Swagger UI). The Lifecycle Object is additional detail used alongside it. @@ -110,10 +110,10 @@ throughout the OAS for this purpose. ```yaml paths: - /v1/pets: + /v1/flights: get: - operationId: listPets_v1 - summary: List all pets + operationId: listFlights_v1 + summary: List all flights deprecated: true features: lifecycle: @@ -123,7 +123,7 @@ paths: description: Migration guide url: https://docs.example.com/migrate replacement: - description: Pets API v2 + description: Flights API v2 url: https://api.example.com/v2/openapi.yaml sunset: date: "2025-07-01T00:00:00Z" From cf6e3da9aa9c70603ed1a3d57bcfe1a24fd6fd97 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Tue, 15 Sep 2026 11:17:32 -0400 Subject: [PATCH 03/12] Update endpoint from '/pets' to '/flights' --- src/lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index b92f438..6d07824 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -62,7 +62,7 @@ is placed under `features` on the Operation Object: ```yaml paths: - /pets: + /flights: get: deprecated: true features: From 4b60bb8ed99c5aa91dcd1f0573d161d1d27b7669 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 29 Oct 2026 10:59:48 -0400 Subject: [PATCH 04/12] Align lifecycle SAF with OAS #5447 features and add external links example - Declare the SAF via the root `features` map (OAS PR #5447) and place the Lifecycle Object in a dedicated Operation Object `lifecycle` field - Add a complete OAS 3.3 example using `externalLinks` from the Docs Links proposal alongside the Lifecycle Object - Fix stale listPets_v2 reference in Appendix B Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 179 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 145 insertions(+), 34 deletions(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 6d07824..3c1a17f 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -50,39 +50,61 @@ alongside it. ## Relationship to the OpenAPI Specification -The Lifecycle Specification defines the **Lifecycle Object**, which appears -under the `features` field introduced in OpenAPI 3.3 as a Standardized API -Feature (SAF) — see +The Lifecycle Specification is a companion specification defining a +[Standardized API Feature](https://spec.openapis.org/oas/v3.3.0.html#standardized-api-features) +(SAF) for OpenAPI 3.3 and later — see [OAI/OpenAPI-Specification discussion #5310](https://github.com/OAI/OpenAPI-Specification/discussions/5310) for background on the SAF concept, and -[discussion #5350](https://github.com/OAI/OpenAPI-Specification/discussions/5350) -for how SAFs are expected to declare their version within a document. Because -the Lifecycle Object is scoped to individual operations (see Introduction), it -is placed under `features` on the Operation Object: +[OAI/OpenAPI-Specification#5447](https://github.com/OAI/OpenAPI-Specification/pull/5447) +for how SAFs are declared. + +An OpenAPI document opts in to this specification by naming it in the +`features` field of the OpenAPI Object, which maps SAF names to the version +of each companion specification in use: + +```yaml +openapi: 3.3.0 +features: + security: 1.0.0 + lifecycle: 1.0.0 +``` + +Because the default value of `features` is `{"security": "1.0.0"}`, a +document that sets `features` explicitly MUST also list `security` to keep +the OAS 3.2-compatible security behavior. As required by the OAS, all +documents in a multi-document OpenAPI Description MUST declare the same SAFs. + +When `lifecycle` is declared in `features`, the Operation Object gains the +`lifecycle` field defined in [Operation Object Fields](#operation-object-fields). +The Lifecycle Object is scoped to individual operations (see Introduction): ```yaml paths: /flights: get: deprecated: true - features: - lifecycle: - # Lifecycle Object fields here + lifecycle: + # Lifecycle Object fields here ``` -As proposed for SAFs generally, a document using this feature is expected to -declare which version of it is in use (for example, via a document-scoped -`usingFeatures` or `usingFeatureGroups` field), independently of the -`openapi` version itself. That mechanism is still being defined by the SAF -work referenced above; this specification will adopt it once it lands rather -than defining a competing one. - Compatibility between versions of this specification and versions of the OpenAPI Specification is determined by publication date. The rules for determining compatible version pairs are documented in [TODO: link or section]. ## Definitions +### Operation Object Fields + +When a document declares `lifecycle` in its `features` field, the following +field is added to the [Operation Object](https://spec.openapis.org/oas/v3.3.0.html#operation-object): + +| Field Name | Type | Description | +| ---- | ---- | ---- | +| `lifecycle` | [Lifecycle Object](#lifecycle-object) | Deprecation and sunset details for this operation. | + +A `lifecycle` field on an Operation Object in a document that does not +declare `lifecycle` in `features` is not governed by this specification. + ### Lifecycle Object A **Lifecycle Object** describes the deprecation and sunset status of a @@ -115,22 +137,110 @@ paths: operationId: listFlights_v1 summary: List all flights deprecated: true - features: - lifecycle: - date: "2025-01-15T00:00:00Z" - reason: "Replaced by v2 API with improved performance." - migration: - description: Migration guide - url: https://docs.example.com/migrate - replacement: - description: Flights API v2 - url: https://api.example.com/v2/openapi.yaml - sunset: - date: "2025-07-01T00:00:00Z" - policy: - url: https://docs.example.com/sunset-policy + lifecycle: + date: "2025-01-15T00:00:00Z" + reason: "Replaced by v2 API with improved performance." + migration: + description: Migration guide + url: https://docs.example.com/migrate + replacement: + description: Flights API v2 + url: https://api.example.com/v2/openapi.yaml + sunset: + date: "2025-07-01T00:00:00Z" + policy: + url: https://docs.example.com/sunset-policy +``` + +#### Complete OpenAPI Description Example + +This example is **informative**. It shows the Lifecycle SAF in a complete +OpenAPI 3.3 document, alongside the `externalLinks` field from the +[Docs Links proposal](https://github.com/OAI/OpenAPI-Specification/blob/main/proposals/2026-08-01-Docs-Links.md). +That proposal replaces the single `externalDocs` field with an +`externalLinks` array and adds optional `summary` and `kind` fields to the +External Documentation Object. Because `migration`, `replacement`, and +`sunset.policy` reuse the External Documentation Object, they gain `summary` +and `kind` without any change to this specification. The `kind` values shown +are illustrative. + +The Lifecycle Object stays the structured source for deprecation and sunset +data. `externalLinks` holds other resources that help consumers through the +transition, such as a changelog or an announcement. + +```yaml +openapi: 3.3.0 +features: + security: 1.0.0 # listed explicitly because `features` is set + lifecycle: 1.0.0 +info: + title: Flights API + version: 2.0.0 +externalLinks: + - url: https://docs.example.com/flights + summary: Flights API documentation + kind: api-docs + - url: https://docs.example.com/changelog + summary: Flights API changelog + kind: changelog +paths: + /v1/flights: + get: + operationId: listFlights_v1 + summary: List all flights + deprecated: true + externalLinks: + - url: https://docs.example.com/changelog#2025-01-15 + summary: Changelog entry announcing the v1 deprecation + kind: changelog + - url: https://status.example.com/announcements/flights-v1-sunset + summary: Sunset announcement + description: | + Timeline, support contacts, and FAQ for the retirement of + `GET /v1/flights`. + kind: announcement + lifecycle: + date: "2025-01-15T00:00:00Z" + reason: "Replaced by v2 API with improved performance." + migration: + summary: Migrating from Flights v1 to v2 + description: | + Step-by-step guide covering renamed query parameters and the + new cursor-based pagination. + url: https://docs.example.com/migrate + kind: migration-guide + replacement: + summary: Flights API v2 + description: Use `listFlights_v2` (`GET /v2/flights`) instead. + url: https://api.example.com/v2/openapi.yaml + kind: openapi + sunset: + date: "2025-07-01T00:00:00Z" + policy: + summary: API sunset policy + url: https://docs.example.com/sunset-policy + kind: policy + responses: + "200": + description: A list of flights. + /v2/flights: + get: + operationId: listFlights_v2 + summary: List all flights + externalLinks: + - url: https://docs.example.com/flights/v2 + summary: Flights API v2 reference + kind: api-docs + responses: + "200": + description: A page of flights. ``` +Only Lifecycle Object fields participate in the +[Header Mapping](#header-mapping); entries in `externalLinks` do not generate +`Deprecation`, `Sunset`, or `Link` headers. A server would emit the same +headers for `GET /v1/flights` here as for the [example](#example) above. + ### Sunset Object A **Sunset Object** describes when an operation is expected to stop accepting @@ -222,8 +332,9 @@ with that shape: applies to. From April 2026 onward, the design shifted to the operation-scoped model in -this document: `deprecation` (renamed `Lifecycle Object` under `features` per -the emerging [SAF](https://github.com/OAI/OpenAPI-Specification/discussions/5310) +this document: `deprecation` (renamed `Lifecycle Object`, placed in the +Operation Object's `lifecycle` field, and enabled through the root `features` +declaration per the [SAF](https://github.com/OAI/OpenAPI-Specification/pull/5447) convention) is the primary object, with `sunset` nested inside it, and `gracePeriod` was dropped as derivable from `deprecation.date` and `sunset.date` together. See @@ -240,7 +351,7 @@ can use the Lifecycle Object in two distinct phases: 1. **Tool registration (design-time, from the spec).** When registering an operation as a tool, a server can fold `reason`, and the `migration` and `replacement` links, into the tool's description — for example, prefixing - it with `"⚠ DEPRECATED — sunset 2025-07-01. Use listPets_v2 instead."` A + it with `"⚠ DEPRECATED — sunset 2025-07-01. Use listFlights_v2 instead."` A well-behaved LLM client that reads this before deciding whether to invoke the tool should prefer the replacement from the outset, without ever calling the deprecated one. From d48630f28da31c8b4d28dcec38d4a47d84f0af58 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 29 Oct 2026 11:02:35 -0400 Subject: [PATCH 05/12] Fix formatting in lifecycle.md for OpenAPI section --- src/lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 3c1a17f..f7deec4 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -171,7 +171,7 @@ transition, such as a changelog or an announcement. ```yaml openapi: 3.3.0 features: - security: 1.0.0 # listed explicitly because `features` is set + security: 1.0.0 lifecycle: 1.0.0 info: title: Flights API From 36a8aefa929956e1c5081bbbb6fa970670c51b3f Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 29 Oct 2026 11:18:18 -0400 Subject: [PATCH 06/12] Replace named lifecycle link fields with an externalLinks array Swap `migration`, `replacement`, and `sunset.policy` for a single `lifecycle.externalLinks` array of External Documentation Objects, per the OAS Docs Links proposal. `kind` is required and uses IANA link relation names (`deprecation`, `successor-version`, `sunset`), so each link maps directly to a `Link` header; other kinds are documentation only. Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 163 ++++++++++++++++++++++++++++------------------- 1 file changed, 97 insertions(+), 66 deletions(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 3c1a17f..39d22b0 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -108,10 +108,11 @@ declare `lifecycle` in `features` is not governed by this specification. ### Lifecycle Object A **Lifecycle Object** describes the deprecation and sunset status of a -single operation: when the deprecation took effect, why, where to find a -migration guide and a replacement, and when the operation will stop accepting -requests. It is optional, and is meant to complement — not replace — -`deprecated: true` on the same Operation Object. +single operation: when the deprecation took effect, why, when the operation +will stop accepting requests, and links to related resources such as a +migration guide, a replacement, and a sunset policy. It is optional, and is +meant to complement — not replace — `deprecated: true` on the same Operation +Object. #### Lifecycle Object Fixed Fields @@ -119,14 +120,36 @@ requests. It is optional, and is meant to complement — not replace — | ---- | ---- | ---- | | `date` | `string` (date-time) | **REQUIRED.** The date the deprecation takes or took effect. | | `reason` | `string` | A human-readable explanation of why the operation is deprecated. | -| `migration` | [External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object) | A description of, and link to, a migration guide. | -| `replacement` | [External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object) | A description of, and link to, the operation or API that replaces this one. | | `sunset` | [Sunset Object](#sunset-object) | Sunset details for this operation. | +| `externalLinks` | [[External Documentation Object](https://spec.openapis.org/oas/v3.3.0.html#external-documentation-object)] | Links to resources related to this operation's deprecation and sunset. Each entry MUST include `kind`. See [Lifecycle Link Kinds](#lifecycle-link-kinds). | -`migration` and `replacement` reuse the OpenAPI Specification's existing -External Documentation Object rather than introducing new object types, since -both are fundamentally a described link — the same shape already used -throughout the OAS for this purpose. +`externalLinks` uses the same array of External Documentation Objects as the +`externalLinks` field on the OpenAPI, Operation, Tag, and Schema Objects (see +the [Docs Links proposal](https://github.com/OAI/OpenAPI-Specification/blob/main/proposals/2026-08-01-Docs-Links.md)), +including its `summary` and `kind` fields. This specification therefore +requires an OpenAPI Specification version that defines those fields. + +Links that concern the operation's deprecation or sunset belong in +`lifecycle.externalLinks`. Other resources, such as general API reference +documentation, belong in the Operation Object's own `externalLinks`. Only +entries in `lifecycle.externalLinks` participate in the +[Header Mapping](#header-mapping). + +#### Lifecycle Link Kinds + +This specification defines the following `kind` values for entries in +`lifecycle.externalLinks`. Each value is the name of an IANA-registered link +relation, so a link's `kind` is also the `rel` of the `Link` header it maps to. + +| `kind` | Meaning | Cardinality | Link relation | +| ---- | ---- | ---- | ---- | +| `deprecation` | Human-readable information about the deprecation, typically a migration guide. | SHOULD appear at most once. | [RFC 9745 §3](https://www.rfc-editor.org/rfc/rfc9745#section-3) | +| `successor-version` | The operation or API that replaces this one. | MAY appear more than once, for example when both a v2 and a v3 replace this operation. | [RFC 5829](https://www.rfc-editor.org/rfc/rfc5829) | +| `sunset` | A human-readable sunset policy. | SHOULD appear at most once. | [RFC 8594 §6](https://www.rfc-editor.org/rfc/rfc8594#section-6) | + +Entries MAY use other `kind` values, such as `changelog` or `announcement`. +Entries with other values are documentation only and have no header +equivalent. #### Example @@ -140,33 +163,30 @@ paths: lifecycle: date: "2025-01-15T00:00:00Z" reason: "Replaced by v2 API with improved performance." - migration: - description: Migration guide - url: https://docs.example.com/migrate - replacement: - description: Flights API v2 - url: https://api.example.com/v2/openapi.yaml sunset: date: "2025-07-01T00:00:00Z" - policy: + externalLinks: + - kind: deprecation + summary: Migration guide + url: https://docs.example.com/migrate + - kind: successor-version + summary: Flights API v2 + url: https://api.example.com/v2/openapi.yaml + - kind: sunset + summary: API sunset policy url: https://docs.example.com/sunset-policy ``` #### Complete OpenAPI Description Example This example is **informative**. It shows the Lifecycle SAF in a complete -OpenAPI 3.3 document, alongside the `externalLinks` field from the -[Docs Links proposal](https://github.com/OAI/OpenAPI-Specification/blob/main/proposals/2026-08-01-Docs-Links.md). -That proposal replaces the single `externalDocs` field with an -`externalLinks` array and adds optional `summary` and `kind` fields to the -External Documentation Object. Because `migration`, `replacement`, and -`sunset.policy` reuse the External Documentation Object, they gain `summary` -and `kind` without any change to this specification. The `kind` values shown -are illustrative. +OpenAPI 3.3 document. The document uses `externalLinks` at three levels: -The Lifecycle Object stays the structured source for deprecation and sunset -data. `externalLinks` holds other resources that help consumers through the -transition, such as a changelog or an announcement. +* at the root, for documentation about the whole API; +* on the Operation Object, for general resources about one operation; +* in the Lifecycle Object, for links about the operation's deprecation and + sunset, including documentation-only kinds such as `changelog` and + `announcement`. ```yaml openapi: 3.3.0 @@ -190,36 +210,37 @@ paths: summary: List all flights deprecated: true externalLinks: - - url: https://docs.example.com/changelog#2025-01-15 - summary: Changelog entry announcing the v1 deprecation - kind: changelog - - url: https://status.example.com/announcements/flights-v1-sunset - summary: Sunset announcement - description: | - Timeline, support contacts, and FAQ for the retirement of - `GET /v1/flights`. - kind: announcement + - url: https://docs.example.com/flights/v1 + summary: Flights API v1 reference + kind: api-docs lifecycle: date: "2025-01-15T00:00:00Z" reason: "Replaced by v2 API with improved performance." - migration: - summary: Migrating from Flights v1 to v2 - description: | - Step-by-step guide covering renamed query parameters and the - new cursor-based pagination. - url: https://docs.example.com/migrate - kind: migration-guide - replacement: - summary: Flights API v2 - description: Use `listFlights_v2` (`GET /v2/flights`) instead. - url: https://api.example.com/v2/openapi.yaml - kind: openapi sunset: date: "2025-07-01T00:00:00Z" - policy: + externalLinks: + - kind: deprecation + summary: Migrating from Flights v1 to v2 + description: | + Step-by-step guide covering renamed query parameters and the + new cursor-based pagination. + url: https://docs.example.com/migrate + - kind: successor-version + summary: Flights API v2 + description: Use `listFlights_v2` (`GET /v2/flights`) instead. + url: https://api.example.com/v2/openapi.yaml + - kind: sunset summary: API sunset policy url: https://docs.example.com/sunset-policy - kind: policy + - kind: changelog + summary: Changelog entry announcing the v1 deprecation + url: https://docs.example.com/changelog#2025-01-15 + - kind: announcement + summary: Sunset announcement + description: | + Timeline, support contacts, and FAQ for the retirement of + `GET /v1/flights`. + url: https://status.example.com/announcements/flights-v1-sunset responses: "200": description: A list of flights. @@ -236,22 +257,21 @@ paths: description: A page of flights. ``` -Only Lifecycle Object fields participate in the -[Header Mapping](#header-mapping); entries in `externalLinks` do not generate -`Deprecation`, `Sunset`, or `Link` headers. A server would emit the same -headers for `GET /v1/flights` here as for the [example](#example) above. +The `changelog` and `announcement` links and the Operation Object's own +`externalLinks` do not generate headers. A server would emit the same headers +for `GET /v1/flights` here as for the [example](#example) above. ### Sunset Object A **Sunset Object** describes when an operation is expected to stop accepting -requests entirely. +requests entirely. A link to a sunset policy is given in the Lifecycle +Object's `externalLinks` with `kind: sunset`. #### Sunset Object Fixed Fields | Field Name | Type | Description | | ---- | ---- | ---- | | `date` | `string` (date-time) | **REQUIRED.** The date after which the operation is expected to stop accepting requests. | -| `policy` | [External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object) | A link to a human-readable sunset policy. | ### Schema Object @@ -295,13 +315,17 @@ following mapping: | Lifecycle Object field | Generated header | RFC | | ---- | ---- | ---- | | `date` | `Deprecation: @` | [RFC 9745 §2.1](https://www.rfc-editor.org/rfc/rfc9745#section-2.1), timestamp format per [RFC 9651 §3.3.7](https://www.rfc-editor.org/rfc/rfc9651#section-3.3.7) | -| `migration.url` | `Link: ; rel="deprecation"; type="text/html"` | [RFC 9745 §3.1](https://www.rfc-editor.org/rfc/rfc9745#section-3.1) + [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | -| `replacement.url` | `Link: ; rel="successor-version"` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | | `sunset.date` | `Sunset: ` | [RFC 8594 §3](https://www.rfc-editor.org/rfc/rfc8594#section-3), date format per [RFC 9110 §5.6.7](https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7) | -| `sunset.policy.url` | `Link: ; rel="sunset"` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | +| `externalLinks` entry with `kind: deprecation` | `Link: ; rel="deprecation"; type="text/html"` | [RFC 9745 §3](https://www.rfc-editor.org/rfc/rfc9745#section-3) + [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | +| `externalLinks` entry with `kind: successor-version` | `Link: ; rel="successor-version"` | [RFC 5829](https://www.rfc-editor.org/rfc/rfc5829) + [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | +| `externalLinks` entry with `kind: sunset` | `Link: ; rel="sunset"` | [RFC 8594 §6](https://www.rfc-editor.org/rfc/rfc8594#section-6) + [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | + +Each `externalLinks` entry with one of these kinds generates its own `Link` +header, so two `successor-version` entries generate two headers. Entries with +any other `kind` do not generate headers. -`reason`, `migration.description`, and `replacement.description` have no -header equivalent — they are documentation-only fields, intended for +`reason`, and the `summary` and `description` of every `externalLinks` entry, +have no header equivalent — they are documentation-only fields, intended for doc-gen and SDK-gen tools (for example, as the body of a generated `@deprecated` annotation). @@ -337,7 +361,14 @@ Operation Object's `lifecycle` field, and enabled through the root `features` declaration per the [SAF](https://github.com/OAI/OpenAPI-Specification/pull/5447) convention) is the primary object, with `sunset` nested inside it, and `gracePeriod` was dropped as derivable from `deprecation.date` and -`sunset.date` together. See +`sunset.date` together. + +In October 2026, the separate `migration`, `replacement`, and `sunset.policy` +link fields were replaced by a single `externalLinks` array in the Lifecycle +Object, aligning with the +[Docs Links proposal](https://github.com/OAI/OpenAPI-Specification/blob/main/proposals/2026-08-01-Docs-Links.md). +Link kinds use IANA link relation names, so each link maps directly to a +`Link` header. See [sig-lifecycle discussion #5](https://github.com/OAI/sig-lifecycle/discussions/5) for the full history. @@ -349,8 +380,8 @@ Servers that expose OpenAPI operations as tools (for example, MCP servers) can use the Lifecycle Object in two distinct phases: 1. **Tool registration (design-time, from the spec).** When registering an - operation as a tool, a server can fold `reason`, and the `migration` and - `replacement` links, into the tool's description — for example, prefixing + operation as a tool, a server can fold `reason`, and the `deprecation` and + `successor-version` links, into the tool's description — for example, prefixing it with `"⚠ DEPRECATED — sunset 2025-07-01. Use listFlights_v2 instead."` A well-behaved LLM client that reads this before deciding whether to invoke the tool should prefer the replacement from the outset, without ever @@ -362,7 +393,7 @@ can use the Lifecycle Object in two distinct phases: and the successor tool, giving the LLM what it needs to relay the warning or retry with the replacement. -Naming the `replacement` link and its `description` clearly (in language an +Writing the `successor-version` link's `summary` and `description` clearly (in language an LLM would recognize as migration- or deprecation-related) is what makes phase 1 effective — the mechanism is ordinary metadata, but the wording carries the signal. From 75607b7ff6a1cd5a1c587c7b91ab03e56e087314 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 29 Oct 2026 11:21:13 -0400 Subject: [PATCH 07/12] Define the Lifecycle Object schema and add pass/fail tests The schema was a stub (`properties: {}` with `additionalProperties: false`) that rejected every non-empty Lifecycle Object. It now validates the Lifecycle, Sunset, and External Documentation Objects, requiring `kind` on each `lifecycle.externalLinks` entry and rejecting the removed `migration`, `replacement`, and `sunset.policy` fields. Removes the unused Schema Object `$dynamicAnchor` hook and the matching spec text, since the Lifecycle Object contains no Schema Objects. Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 15 ++-- src/schemas/validation/schema.yaml | 69 ++++++++++++++++--- tests/schema/fail/date-not-string.yaml | 1 + tests/schema/fail/lifecycle-not-object.yaml | 1 + .../fail/link-description-not-string.yaml | 5 ++ tests/schema/fail/link-kind-not-string.yaml | 4 ++ tests/schema/fail/link-missing-kind.yaml | 4 ++ tests/schema/fail/link-missing-url.yaml | 4 ++ tests/schema/fail/link-not-object.yaml | 3 + .../schema/fail/link-summary-not-string.yaml | 5 ++ tests/schema/fail/link-unknown-field.yaml | 5 ++ tests/schema/fail/link-url-not-string.yaml | 4 ++ tests/schema/fail/links-not-array.yaml | 4 ++ tests/schema/fail/missing-date.yaml | 1 + tests/schema/fail/reason-not-string.yaml | 2 + .../schema/fail/removed-migration-field.yaml | 4 ++ .../fail/removed-replacement-field.yaml | 4 ++ .../fail/removed-sunset-policy-field.yaml | 6 ++ tests/schema/fail/sunset-date-not-string.yaml | 3 + tests/schema/fail/sunset-missing-date.yaml | 2 + tests/schema/fail/sunset-not-object.yaml | 2 + tests/schema/pass/empty-links.yaml | 2 + .../schema/pass/lifecycle-object-example.yaml | 25 +++++++ tests/schema/pass/link-extension.yaml | 6 ++ tests/schema/pass/minimal.yaml | 2 + tests/schema/pass/multiple-successors.yaml | 7 ++ tests/schema/schema.test.mjs | 30 ++++++-- 27 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 tests/schema/fail/date-not-string.yaml create mode 100644 tests/schema/fail/lifecycle-not-object.yaml create mode 100644 tests/schema/fail/link-description-not-string.yaml create mode 100644 tests/schema/fail/link-kind-not-string.yaml create mode 100644 tests/schema/fail/link-missing-kind.yaml create mode 100644 tests/schema/fail/link-missing-url.yaml create mode 100644 tests/schema/fail/link-not-object.yaml create mode 100644 tests/schema/fail/link-summary-not-string.yaml create mode 100644 tests/schema/fail/link-unknown-field.yaml create mode 100644 tests/schema/fail/link-url-not-string.yaml create mode 100644 tests/schema/fail/links-not-array.yaml create mode 100644 tests/schema/fail/missing-date.yaml create mode 100644 tests/schema/fail/reason-not-string.yaml create mode 100644 tests/schema/fail/removed-migration-field.yaml create mode 100644 tests/schema/fail/removed-replacement-field.yaml create mode 100644 tests/schema/fail/removed-sunset-policy-field.yaml create mode 100644 tests/schema/fail/sunset-date-not-string.yaml create mode 100644 tests/schema/fail/sunset-missing-date.yaml create mode 100644 tests/schema/fail/sunset-not-object.yaml create mode 100644 tests/schema/pass/empty-links.yaml create mode 100644 tests/schema/pass/lifecycle-object-example.yaml create mode 100644 tests/schema/pass/link-extension.yaml create mode 100644 tests/schema/pass/minimal.yaml create mode 100644 tests/schema/pass/multiple-successors.yaml diff --git a/src/lifecycle.md b/src/lifecycle.md index 57c5b36..4ccc255 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -273,12 +273,6 @@ Object's `externalLinks` with `kind: sunset`. | ---- | ---- | ---- | | `date` | `string` (date-time) | **REQUIRED.** The date after which the operation is expected to stop accepting requests. | -### Schema Object - -A **Schema Object** is a JSON Schema schema as defined by the OpenAPI Specification -in use. Within this document, Schema Objects behave according to the OAS dialect -in effect in the containing OpenAPI document. - ## Specification ### Version `1.0` @@ -296,10 +290,11 @@ interpreted as described in [RFC2119](https://tools.ietf.org/html/rfc2119). Implementations that validate Lifecycle Objects MUST use the schema published at `https://spec.openapis.org/lifecycle/1.0/schema/WORK-IN-PROGRESS`. -When validating a Lifecycle Object within an OpenAPI document, the Schema Object -schema in effect is determined by the OAS dialect declared in that document. -Standalone validation of Lifecycle Objects uses the standard JSON Schema -2020-12 dialect for Schema Object positions. +The Lifecycle Object contains no Schema Objects, so validation does not depend +on the OAS dialect in effect in the containing document. The schema includes +its own definition of the External Documentation Object, as extended by the +[Docs Links proposal](https://github.com/OAI/OpenAPI-Specification/blob/main/proposals/2026-08-01-Docs-Links.md), +and requires `kind` on each entry in `externalLinks`. ### Header Mapping diff --git a/src/schemas/validation/schema.yaml b/src/schemas/validation/schema.yaml index c387f3c..bccf741 100644 --- a/src/schemas/validation/schema.yaml +++ b/src/schemas/validation/schema.yaml @@ -3,16 +3,63 @@ $id: 'https://spec.openapis.org/lifecycle/1.0/schema/WORK-IN-PROGRESS' title: Lifecycle Schema description: Validates a Lifecycle Object. -# $dynamicAnchor marks Schema Object positions as an integration point. -# When this schema is used standalone, $dynamicRef resolves to the standard -# JSON Schema 2020-12 definition immediately below. -# When composed with an OAS metaschema (integration mechanism TBD), the OAS -# dialect fills in this anchor with the full OAS Schema Object definition. +$ref: '#/$defs/lifecycle' + $defs: - schema-object: - $dynamicAnchor: schema-object - $ref: 'https://json-schema.org/draft/2020-12/schema' + lifecycle: + description: Deprecation and sunset status of a single operation. + type: object + required: + - date + properties: + date: + type: string + format: date-time + reason: + type: string + sunset: + $ref: '#/$defs/sunset' + externalLinks: + type: array + items: + $ref: '#/$defs/lifecycle-link' + unevaluatedProperties: false + + sunset: + description: When an operation is expected to stop accepting requests. + type: object + required: + - date + properties: + date: + type: string + format: date-time + unevaluatedProperties: false + + # An External Documentation Object in lifecycle.externalLinks, where kind is required. + lifecycle-link: + $ref: '#/$defs/external-documentation' + required: + - kind + + # External Documentation Object as extended by the OAS Docs Links proposal. + external-documentation: + type: object + required: + - url + properties: + summary: + type: string + description: + type: string + url: + type: string + format: uri + kind: + type: string + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false -type: object -properties: {} -additionalProperties: false + specification-extensions: + patternProperties: + '^x-': true diff --git a/tests/schema/fail/date-not-string.yaml b/tests/schema/fail/date-not-string.yaml new file mode 100644 index 0000000..8c20cdd --- /dev/null +++ b/tests/schema/fail/date-not-string.yaml @@ -0,0 +1 @@ +date: 1736899200 diff --git a/tests/schema/fail/lifecycle-not-object.yaml b/tests/schema/fail/lifecycle-not-object.yaml new file mode 100644 index 0000000..4f01ad3 --- /dev/null +++ b/tests/schema/fail/lifecycle-not-object.yaml @@ -0,0 +1 @@ +- date: "2025-01-15T00:00:00Z" diff --git a/tests/schema/fail/link-description-not-string.yaml b/tests/schema/fail/link-description-not-string.yaml new file mode 100644 index 0000000..43162f9 --- /dev/null +++ b/tests/schema/fail/link-description-not-string.yaml @@ -0,0 +1,5 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + description: 1 + url: https://docs.example.com/migrate diff --git a/tests/schema/fail/link-kind-not-string.yaml b/tests/schema/fail/link-kind-not-string.yaml new file mode 100644 index 0000000..5a474b8 --- /dev/null +++ b/tests/schema/fail/link-kind-not-string.yaml @@ -0,0 +1,4 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: 1 + url: https://docs.example.com/migrate diff --git a/tests/schema/fail/link-missing-kind.yaml b/tests/schema/fail/link-missing-kind.yaml new file mode 100644 index 0000000..d907f19 --- /dev/null +++ b/tests/schema/fail/link-missing-kind.yaml @@ -0,0 +1,4 @@ +# kind is REQUIRED in lifecycle.externalLinks +date: "2025-01-15T00:00:00Z" +externalLinks: + - url: https://docs.example.com/migrate diff --git a/tests/schema/fail/link-missing-url.yaml b/tests/schema/fail/link-missing-url.yaml new file mode 100644 index 0000000..c654697 --- /dev/null +++ b/tests/schema/fail/link-missing-url.yaml @@ -0,0 +1,4 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + summary: Migration guide diff --git a/tests/schema/fail/link-not-object.yaml b/tests/schema/fail/link-not-object.yaml new file mode 100644 index 0000000..94d6929 --- /dev/null +++ b/tests/schema/fail/link-not-object.yaml @@ -0,0 +1,3 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - https://docs.example.com/migrate diff --git a/tests/schema/fail/link-summary-not-string.yaml b/tests/schema/fail/link-summary-not-string.yaml new file mode 100644 index 0000000..c61817e --- /dev/null +++ b/tests/schema/fail/link-summary-not-string.yaml @@ -0,0 +1,5 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + summary: 1 + url: https://docs.example.com/migrate diff --git a/tests/schema/fail/link-unknown-field.yaml b/tests/schema/fail/link-unknown-field.yaml new file mode 100644 index 0000000..d1ebc2e --- /dev/null +++ b/tests/schema/fail/link-unknown-field.yaml @@ -0,0 +1,5 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + name: Migration guide + url: https://docs.example.com/migrate diff --git a/tests/schema/fail/link-url-not-string.yaml b/tests/schema/fail/link-url-not-string.yaml new file mode 100644 index 0000000..cbda956 --- /dev/null +++ b/tests/schema/fail/link-url-not-string.yaml @@ -0,0 +1,4 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + url: 1 diff --git a/tests/schema/fail/links-not-array.yaml b/tests/schema/fail/links-not-array.yaml new file mode 100644 index 0000000..1232d80 --- /dev/null +++ b/tests/schema/fail/links-not-array.yaml @@ -0,0 +1,4 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: + kind: deprecation + url: https://docs.example.com/migrate diff --git a/tests/schema/fail/missing-date.yaml b/tests/schema/fail/missing-date.yaml new file mode 100644 index 0000000..f633745 --- /dev/null +++ b/tests/schema/fail/missing-date.yaml @@ -0,0 +1 @@ +reason: "Replaced by v2 API with improved performance." diff --git a/tests/schema/fail/reason-not-string.yaml b/tests/schema/fail/reason-not-string.yaml new file mode 100644 index 0000000..e78e3db --- /dev/null +++ b/tests/schema/fail/reason-not-string.yaml @@ -0,0 +1,2 @@ +date: "2025-01-15T00:00:00Z" +reason: 42 diff --git a/tests/schema/fail/removed-migration-field.yaml b/tests/schema/fail/removed-migration-field.yaml new file mode 100644 index 0000000..18b05f8 --- /dev/null +++ b/tests/schema/fail/removed-migration-field.yaml @@ -0,0 +1,4 @@ +# migration was replaced by externalLinks with kind: deprecation +date: "2025-01-15T00:00:00Z" +migration: + url: https://docs.example.com/migrate diff --git a/tests/schema/fail/removed-replacement-field.yaml b/tests/schema/fail/removed-replacement-field.yaml new file mode 100644 index 0000000..3f48399 --- /dev/null +++ b/tests/schema/fail/removed-replacement-field.yaml @@ -0,0 +1,4 @@ +# replacement was replaced by externalLinks with kind: successor-version +date: "2025-01-15T00:00:00Z" +replacement: + url: https://api.example.com/v2/openapi.yaml diff --git a/tests/schema/fail/removed-sunset-policy-field.yaml b/tests/schema/fail/removed-sunset-policy-field.yaml new file mode 100644 index 0000000..5a899ea --- /dev/null +++ b/tests/schema/fail/removed-sunset-policy-field.yaml @@ -0,0 +1,6 @@ +# sunset.policy was replaced by externalLinks with kind: sunset +date: "2025-01-15T00:00:00Z" +sunset: + date: "2025-07-01T00:00:00Z" + policy: + url: https://docs.example.com/sunset-policy diff --git a/tests/schema/fail/sunset-date-not-string.yaml b/tests/schema/fail/sunset-date-not-string.yaml new file mode 100644 index 0000000..7b562f7 --- /dev/null +++ b/tests/schema/fail/sunset-date-not-string.yaml @@ -0,0 +1,3 @@ +date: "2025-01-15T00:00:00Z" +sunset: + date: 1751328000 diff --git a/tests/schema/fail/sunset-missing-date.yaml b/tests/schema/fail/sunset-missing-date.yaml new file mode 100644 index 0000000..0130174 --- /dev/null +++ b/tests/schema/fail/sunset-missing-date.yaml @@ -0,0 +1,2 @@ +date: "2025-01-15T00:00:00Z" +sunset: {} diff --git a/tests/schema/fail/sunset-not-object.yaml b/tests/schema/fail/sunset-not-object.yaml new file mode 100644 index 0000000..b61d6f4 --- /dev/null +++ b/tests/schema/fail/sunset-not-object.yaml @@ -0,0 +1,2 @@ +date: "2025-01-15T00:00:00Z" +sunset: "2025-07-01T00:00:00Z" diff --git a/tests/schema/pass/empty-links.yaml b/tests/schema/pass/empty-links.yaml new file mode 100644 index 0000000..d004d67 --- /dev/null +++ b/tests/schema/pass/empty-links.yaml @@ -0,0 +1,2 @@ +date: "2025-01-15T00:00:00Z" +externalLinks: [] diff --git a/tests/schema/pass/lifecycle-object-example.yaml b/tests/schema/pass/lifecycle-object-example.yaml new file mode 100644 index 0000000..cf56689 --- /dev/null +++ b/tests/schema/pass/lifecycle-object-example.yaml @@ -0,0 +1,25 @@ +# Lifecycle Object from the spec's Complete OpenAPI Description Example +date: "2025-01-15T00:00:00Z" +reason: "Replaced by v2 API with improved performance." +sunset: + date: "2025-07-01T00:00:00Z" +externalLinks: + - kind: deprecation + summary: Migrating from Flights v1 to v2 + description: | + Step-by-step guide covering renamed query parameters and the + new cursor-based pagination. + url: https://docs.example.com/migrate + - kind: successor-version + summary: Flights API v2 + description: Use `listFlights_v2` (`GET /v2/flights`) instead. + url: https://api.example.com/v2/openapi.yaml + - kind: sunset + summary: API sunset policy + url: https://docs.example.com/sunset-policy + - kind: changelog + summary: Changelog entry announcing the v1 deprecation + url: https://docs.example.com/changelog#2025-01-15 + - kind: announcement + summary: Sunset announcement + url: https://status.example.com/announcements/flights-v1-sunset diff --git a/tests/schema/pass/link-extension.yaml b/tests/schema/pass/link-extension.yaml new file mode 100644 index 0000000..fb5a8d2 --- /dev/null +++ b/tests/schema/pass/link-extension.yaml @@ -0,0 +1,6 @@ +# External Documentation Objects MAY be extended with Specification Extensions +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + url: https://docs.example.com/migrate + x-audience: internal diff --git a/tests/schema/pass/minimal.yaml b/tests/schema/pass/minimal.yaml new file mode 100644 index 0000000..8bbceaf --- /dev/null +++ b/tests/schema/pass/minimal.yaml @@ -0,0 +1,2 @@ +# Only the required field +date: "2025-01-15T00:00:00Z" diff --git a/tests/schema/pass/multiple-successors.yaml b/tests/schema/pass/multiple-successors.yaml new file mode 100644 index 0000000..f274aac --- /dev/null +++ b/tests/schema/pass/multiple-successors.yaml @@ -0,0 +1,7 @@ +# successor-version MAY appear more than once +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: successor-version + url: https://api.example.com/v2/openapi.yaml + - kind: successor-version + url: https://api.example.com/v3/openapi.yaml diff --git a/tests/schema/schema.test.mjs b/tests/schema/schema.test.mjs index 04fed01..b39e455 100644 --- a/tests/schema/schema.test.mjs +++ b/tests/schema/schema.test.mjs @@ -1,11 +1,29 @@ -import { describe, expect, it } from "@oai/build-infra/test"; -import { registerSchema } from "@oai/build-infra/schema/vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import YAML from "yaml"; +import { registerSchema, toMatchJsonSchema } from "@oai/build-infra/schema/vitest"; +import { describe, expect, test } from "@oai/build-infra/test"; +expect.extend({ toMatchJsonSchema }); + +const schemaUri = "https://spec.openapis.org/lifecycle/1.0/schema/WORK-IN-PROGRESS"; await registerSchema("./src/schemas/validation/schema.yaml"); -// Placeholder test suite - add pass/fail test cases as the schema is developed. describe("Lifecycle schema", () => { - it("validates an empty Lifecycle Object", async () => { - await expect({}).to.matchJsonSchema("./src/schemas/validation/schema.yaml"); - }); + for (const entry of readdirSync("tests/schema/pass", { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".yaml")) continue; + + test(`pass/${entry.name}`, async () => { + const document = YAML.parse(readFileSync(`tests/schema/pass/${entry.name}`, "utf8")); + await expect(document).toMatchJsonSchema(schemaUri); + }); + } + + for (const entry of readdirSync("tests/schema/fail", { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".yaml")) continue; + + test(`fail/${entry.name}`, async () => { + const document = YAML.parse(readFileSync(`tests/schema/fail/${entry.name}`, "utf8")); + await expect(document).not.toMatchJsonSchema(schemaUri); + }); + } }); From a5f5208e99ce436e51927729aca7e3813d6ee2d3 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 29 Oct 2026 11:28:59 -0400 Subject: [PATCH 08/12] Make `deprecated` take precedence over the Lifecycle Object Define `deprecated` as the authoritative deprecation signal. Operations with `lifecycle` SHOULD set `deprecated: true`; if `deprecated` is absent or false, tools MUST treat the operation as not deprecated and MUST NOT generate headers or present it as deprecated from the Lifecycle Object. Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 4ccc255..f11937a 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -135,6 +135,25 @@ documentation, belong in the Operation Object's own `externalLinks`. Only entries in `lifecycle.externalLinks` participate in the [Header Mapping](#header-mapping). +#### Relationship to `deprecated` + +The Operation Object's `deprecated` field is the authoritative signal of +whether an operation is deprecated, and takes precedence over the Lifecycle +Object. The Lifecycle Object only adds detail to an operation that +`deprecated` already marks as deprecated. + +An Operation Object with a `lifecycle` field SHOULD set `deprecated: true`. + +If `deprecated` is absent or `false`, tools MUST treat the operation as not +deprecated, even if a `lifecycle` field is present. In that case, tools: + +* MUST NOT generate `Deprecation`, `Sunset`, or `Link` headers from the + Lifecycle Object; +* MUST NOT present the operation as deprecated or scheduled for sunset, for + example in generated documentation, `@deprecated` annotations, or tool + descriptions; +* MAY warn that the `lifecycle` field is ignored. + #### Lifecycle Link Kinds This specification defines the following `kind` values for entries in @@ -302,7 +321,9 @@ This section is **informative**. The Lifecycle Object describes deprecation and sunset *behavior*; the HTTP headers below are a byproduct a server MAY choose to generate from that description at runtime. An OpenAPI description alone does not cause a server to emit these headers — that requires runtime -support in the API implementation or gateway. +support in the API implementation or gateway. Headers are only generated for +operations with `deprecated: true`; see +[Relationship to `deprecated`](#relationship-to-deprecated). Tools that do generate headers from a Lifecycle Object SHOULD use the following mapping: From 07d7d2dc952fe886964b4436057840af8a941bc5 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 29 Oct 2026 11:30:39 -0400 Subject: [PATCH 09/12] Note that deprecated: true alone is unchanged by the Lifecycle SAF Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lifecycle.md b/src/lifecycle.md index f11937a..89c71af 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -154,6 +154,14 @@ deprecated, even if a `lifecycle` field is present. In that case, tools: descriptions; * MAY warn that the `lifecycle` field is ignored. +This specification does not change the meaning of `deprecated: true` when +there is no `lifecycle` field. An operation can be marked deprecated without +this SAF, as before, which allows APIs to adopt the Lifecycle Object +gradually. The full value comes from using both: `deprecated: true` as the +signal that existing tools already recognize, and the Lifecycle Object for +the dates, reason, and links that tools need to explain the deprecation and +generate headers. + #### Lifecycle Link Kinds This specification defines the following `kind` values for entries in From 0c109110ca78dcd8cedcf12bf0ad7001128c6787 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Fri, 30 Oct 2026 11:38:00 -0400 Subject: [PATCH 10/12] Use uri-reference format for External Documentation Object url For consistency with the OAS schema, allow URI references, which are resolved against the document's base URI. Co-Authored-By: Claude Opus 5.5 --- src/schemas/validation/schema.yaml | 2 +- tests/schema/pass/relative-link-url.yaml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 tests/schema/pass/relative-link-url.yaml diff --git a/src/schemas/validation/schema.yaml b/src/schemas/validation/schema.yaml index bccf741..faf2a03 100644 --- a/src/schemas/validation/schema.yaml +++ b/src/schemas/validation/schema.yaml @@ -54,7 +54,7 @@ $defs: type: string url: type: string - format: uri + format: uri-reference kind: type: string $ref: '#/$defs/specification-extensions' diff --git a/tests/schema/pass/relative-link-url.yaml b/tests/schema/pass/relative-link-url.yaml new file mode 100644 index 0000000..abb6971 --- /dev/null +++ b/tests/schema/pass/relative-link-url.yaml @@ -0,0 +1,5 @@ +# url is a URI reference, so relative references are allowed +date: "2025-01-15T00:00:00Z" +externalLinks: + - kind: deprecation + url: ../docs/migrate.html From 3f02cb7ed0225debbc086f68639ab6bc01725264 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Fri, 30 Oct 2026 11:43:02 -0400 Subject: [PATCH 11/12] Point OAS links at pages that exist until OAS 3.3 is published spec.openapis.org/oas/v3.3.0.html is not published yet, so the link check fails. Link the SAF section to the v3.3-dev source and the Operation and External Documentation Objects to the latest published OAS. Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 89c71af..5f29ab7 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -51,7 +51,7 @@ alongside it. ## Relationship to the OpenAPI Specification The Lifecycle Specification is a companion specification defining a -[Standardized API Feature](https://spec.openapis.org/oas/v3.3.0.html#standardized-api-features) +[Standardized API Feature](https://github.com/OAI/OpenAPI-Specification/blob/v3.3-dev/src/oas.md#standardized-api-features) (SAF) for OpenAPI 3.3 and later — see [OAI/OpenAPI-Specification discussion #5310](https://github.com/OAI/OpenAPI-Specification/discussions/5310) for background on the SAF concept, and @@ -96,7 +96,7 @@ compatible version pairs are documented in [TODO: link or section]. ### Operation Object Fields When a document declares `lifecycle` in its `features` field, the following -field is added to the [Operation Object](https://spec.openapis.org/oas/v3.3.0.html#operation-object): +field is added to the [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object): | Field Name | Type | Description | | ---- | ---- | ---- | @@ -121,7 +121,7 @@ Object. | `date` | `string` (date-time) | **REQUIRED.** The date the deprecation takes or took effect. | | `reason` | `string` | A human-readable explanation of why the operation is deprecated. | | `sunset` | [Sunset Object](#sunset-object) | Sunset details for this operation. | -| `externalLinks` | [[External Documentation Object](https://spec.openapis.org/oas/v3.3.0.html#external-documentation-object)] | Links to resources related to this operation's deprecation and sunset. Each entry MUST include `kind`. See [Lifecycle Link Kinds](#lifecycle-link-kinds). | +| `externalLinks` | [[External Documentation Object](https://spec.openapis.org/oas/latest.html#external-documentation-object)] | Links to resources related to this operation's deprecation and sunset. Each entry MUST include `kind`. See [Lifecycle Link Kinds](#lifecycle-link-kinds). | `externalLinks` uses the same array of External Documentation Objects as the `externalLinks` field on the OpenAPI, Operation, Tag, and Schema Objects (see From 0371a891e634ee19d507e90da890bb5fe026ae4a Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Thu, 5 Nov 2026 21:51:15 -0500 Subject: [PATCH 12/12] Show header output for the complete example and note provisional placement Add the Deprecation, Sunset, and Link headers a server would emit for the complete example, with the Lifecycle Object field each comes from. Add an editor's note that the Lifecycle Object will move under the SAF container field once the OAS defines it. Co-Authored-By: Claude Opus 5.5 --- src/lifecycle.md | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/lifecycle.md b/src/lifecycle.md index 5f29ab7..09efc4c 100644 --- a/src/lifecycle.md +++ b/src/lifecycle.md @@ -87,6 +87,14 @@ paths: # Lifecycle Object fields here ``` +**Editor's note.** The placement of the Lifecycle Object is provisional. +SAFs are expected to place their objects under a container field that the OAS +will define on the OpenAPI, Path Item, and Operation Objects, so that SAFs +do not add fields directly to core objects. Once the OAS defines that +container, the Lifecycle Object will move under it, and the `lifecycle` field +described in [Operation Object Fields](#operation-object-fields) will be +replaced. + Compatibility between versions of this specification and versions of the OpenAPI Specification is determined by publication date. The rules for determining compatible version pairs are documented in [TODO: link or section]. @@ -284,9 +292,31 @@ paths: description: A page of flights. ``` -The `changelog` and `announcement` links and the Operation Object's own -`externalLinks` do not generate headers. A server would emit the same headers -for `GET /v1/flights` here as for the [example](#example) above. +Applying the [Header Mapping](#header-mapping), a server would emit the +following headers in responses to `GET /v1/flights`: + +```http +Deprecation: @1736899200 +Sunset: Tue, 01 Jul 2025 00:00:00 GMT +Link: ; rel="deprecation"; type="text/html" +Link: ; rel="successor-version" +Link: ; rel="sunset" +``` + +Each header comes from one Lifecycle Object field: + +| Header | Source in the Lifecycle Object | +| ---- | ---- | +| `Deprecation: @1736899200` | `date: "2025-01-15T00:00:00Z"` | +| `Sunset: Tue, 01 Jul 2025 00:00:00 GMT` | `sunset.date: "2025-07-01T00:00:00Z"` | +| `Link: <…/migrate>; rel="deprecation"` | `externalLinks` entry with `kind: deprecation` | +| `Link: <…/v2/openapi.yaml>; rel="successor-version"` | `externalLinks` entry with `kind: successor-version` | +| `Link: <…/sunset-policy>; rel="sunset"` | `externalLinks` entry with `kind: sunset` | + +The `changelog` and `announcement` entries in `lifecycle.externalLinks`, and +the Operation Object's own `externalLinks`, do not generate headers. +`GET /v2/flights` has no Lifecycle Object, so its responses carry none of +these headers. ### Sunset Object