Operation Lifecycle
In brief
x-audiencesays who an operation is for.x-lifecyclesays how strong a promise it carries.- A public operation that declares no
x-lifecycleisbeta: unstable until somebody says otherwise.deprecatedis OpenAPI's own key, so it stays out ofx-lifecycleand the package reads both.- The doctor enforces the sunset rules today. It reports
stablewithout protecting it yet, and the protection report says so.- Not built yet: the RFC 8594
Sunsetheaders the generated code will send, in Phase 2.
Partly shipped. The three extension keys are read with their defaults resolved, and the doctor rules over them run: a deprecation with no
x-sunset, a date that has passed, a date nothing can read, an unrecognizedx-lifecyclevalue (refused at read time, so it is reported under the doctor's Document validity section rather than its Lifecycle one), thebetalisting, and the protection report. Not built yet: breaking-change enforcement, sox-lifecycle: stableis a declaration the doctor reports on rather than a rule that fails a build, and the RFC 8594 headers the generated code will emit. Both are in the Roadmap. Items markedOpenare undecided.
What the three keys say
# openapi.yaml
paths:
/orders/{id}:
get:
operationId: showOrder
x-audience: public
x-lifecycle: stable
deprecated: true
x-sunset: 2026-06-01OpenAPI can say an operation is deprecated. It cannot say how strong a promise the operation carries before that, nor when it disappears, which is the only part a consumer can plan around. This document owns the extensions that close the gap, and the enforcement that gives them teeth.
The three keys, and where each one goes:
| Key | Where | Value |
|---|---|---|
x-audience | Operation | public or internal. Absent means public. |
x-lifecycle | Operation | beta or stable. Its default depends on the audience. |
x-sunset | Operation | The date the endpoint stops being served. |
The audience sets the default
How strong a promise an operation carries, and who it is promised to, are two different questions. Two axes, so two keys, with the audience acting as the discriminator that sets the other's default:
x-audience | Default x-lifecycle | Reasoning |
|---|---|---|
public (default) | beta | Somebody outside this codebase may depend on it. The stage is a claim you have to make. |
internal | none | Same application on both ends. Requiring a lifecycle stage on every internal route is ceremony for a promise nobody asked for. |
One consequence worth having: the doctor's protection report counts public operations only. A monolith with two hundred internal routes should not have its 0 of 47 public operations are stable finding drowned by endpoints that were never promised to anyone.
Why the audience decides
Three constraints make this safe rather than merely convenient:
x-audienceitself defaults topublic. This is not a coin flip. It is the same principle as defaulting tobeta. Omission must never be the cheaper path to less protection, because omission is what happens when a spec is imported, generated, or written in a hurry. Declaring an endpoint internal is an act; being treated as public is what happens by default.- A missing lifecycle is the absence of a claim, not a prohibition. An internal endpoint can still declare
x-lifecycle: stable, and it can still bedeprecatedwith a fullx-sunsettreatment: internal consumers deserve a removal date as much as anyone. They simply do not need a promise on every route to get one. - Demoting
publictointernalis reported. This is the hole the composite otherwise opens: once a breaking change to astableoperation fails the build, flipping its audience tointernalmakes the failure disappear. That may be entirely legitimate, since an endpoint really can stop being public, but it is revoking a promise, and a promise cannot be revoked silently in a package built on contracts. The report names it, in the same spirit as labelling a non-representative run. Whether it merely reports or requires the sameinfo.versionbump a break would is open.
deprecated stays out of x-lifecycle
OpenAPI already has deprecated: true on an operation. Putting deprecated in x-lifecycle as well would create a second place to state one fact, the failure this whole package exists to prevent, so it is not in the value set at all. x-lifecycle says how strong the promise is. deprecated says the operation is going away. They are independent, and both can be true.
The two tracks of an operation's lifecycle. They move independently, which is the whole of the design.
Why it stays native
Removing it costs nothing and buys two things. There is no agreement rule to write, because there is nothing to disagree with. And an operation can be stable and deprecated, which is not a contradiction but the normal, well-behaved case: a promise being honored right up to its stated removal date is exactly what a good deprecation looks like.
x-lifecycle is then a binary, and what it adds to OpenAPI is one word the specification has no way to express: whether an operation is promised at all. The two tracks are drawn side by side above because that is the shape this argument takes, and the shape a single chain of states would get wrong.
What the doctor enforces
Six rules follow from the three keys, and the reason is in the second column:
| Rule | Why |
|---|---|
deprecated: true requires x-sunset | The strongest rule here. A deprecation with no end date is a wish. Requiring the date turns "we should remove this someday" into a commitment with a review attached. |
x-sunset in the past is a finding | You are serving an endpoint you promised to remove. Nothing else in the system will ever notice. |
x-sunset approaching is a warning | With a configurable horizon, lifecycle.sunset_horizon_days, 90 days by default, so it lands in CI while there is still time to act. It decides what is mentioned, never what fails: an approaching date is reported beside the protection report rather than as a finding, because a horizon nobody tuned must not turn a pipeline red on a day nobody committed anything. |
An unrecognized x-lifecycle value is a finding | Extensions are untyped by nature: x-lifecycle: stabel is silent everywhere else in the toolchain. Refused where the document is read, so the doctor reports it under Document validity with every other refusal of that class rather than a second time here. |
beta operations are listed | The unstable surface of an API, on one screen, is worth printing even when nothing is wrong. |
A public + stable operation without operationId is a finding | Promoting an operation to stable is the moment its generated class name stops being disposable. See naming. |
A date-only sunset passes at midnight
x-sunset: 2026-06-01 states a calendar day, and a day is not a moment. The rule resolves it to 2026-06-01T00:00:00Z and reports the endpoint as still-served-after-removal from that instant, so the last day an operation can be served without a finding is 2026-05-31, the day before the date it states. Both halves of the section agree on it: a sunset falling today never prints as "sunset in 0 day(s)" among the approaching ones, because by then it has already passed.
Stated here rather than left to be derived, because the alternative reading is just as defensible, treating the stated day as the last served day, and a boundary a reader has to infer from a comparison operator is one they will infer wrongly at least once. Write a moment (2026-06-01T12:00:00Z) when the hour matters.
The horizon key may be missing
lifecycle.sunset_horizon_days arrived with the doctor's Lifecycle section. A project that published config/lara-spec-first.php before that has no such key: config() returns null, the horizon falls back to 90 days, and nothing breaks. But the key is invisible in the one file that team reads to find out what they can tune. Re-publish the config, or add the key by hand.
Public operations default to beta
A public operation with no x-lifecycle is beta. You cannot claim a stability guarantee by omission, because claiming one is an act. This is the right default for the same reason the allowlist is empty by default: the permissive state is the one you should have to opt out of, not into.
What each value promises, and what the build does with it:
| Value | Means | What the build does |
|---|---|---|
beta (default when public) | Not yet promised to anyone. | Permissive. Change it freely. |
stable | A production consumer depends on this. | A breaking change fails the build. |
| none (default when internal) | No claim made, and none expected. | Permissive by intent, not by neglect. |
Orthogonal to all of them, deprecated: true remains native and can accompany any value.
stable is worth promoting to the moment one production consumer exists, unless that consumer knowingly signed up for instability, which is what x-audience: internal records.
Four things follow, because a rule that fails a build has to be right.
The baseline comes from git
Failing on a breaking change requires a baseline, and the baseline is the specification itself, its previously committed version. Not the generated code, which was never meant to carry the whole contract, and not a separate file the build writes: the specification is the only artifact this package keeps, so there is nothing else to compare against. git show against the merge base is the mechanism, in the same spirit as git being the lock file for vendored references. Which means the comparison needs history to exist, and the doctor is where a shallow clone or an untracked specification gets caught, before it is mistaken for "nothing changed".
Breaking runs in two directions
"Breaking" is directional, and the direction inverts between request and response. This is where implementations get it wrong, so it has to be a written table rather than a judgement call:
| Change | On the request | On the response |
|---|---|---|
| Adding a required field | Breaking. Existing callers omit it. | Not breaking, in the ordinary case. |
| Removing an optional field | Not breaking, in the ordinary case. | Breaking. A consumer was reading it. |
| Widening an enum | Helps senders. Not breaking. | Breaking. A consumer must handle a value it has never seen. |
| Narrowing an enum | Breaking. A value that was accepted no longer is. | Not breaking. |
That table is itself public API under rule 4: a change to what counts as breaking changes whose build fails. Its exhaustive form is large enough to deserve its own phase rather than being smuggled into the first release.
info.version is the escape hatch
The escape hatch already exists in the document. A build that only says you broke a stable operation is an obstacle. A build that says this change requires info.version to go from 2.4.1 to 3.0.0, and will pass once it does has turned enforcement into instruction. It needs no config, no flag and no acknowledgement entry: the contract carries its own version, and deliberately breaking one becomes indistinguishable from publishing a major, which is exactly what it should be. Breaking on purpose stays possible; breaking by accident stops being.
The doctor counts what is protected
A specification imported from elsewhere has no x-lifecycle anywhere, so every public operation defaults to beta and the strongest rule in this document is silently off for the whole API. 47 public operations, 0 stable is a finding under rule 2: protection that is off must never look like protection that passed.
Generated code announces the sunset
This is what makes these keys worth defining rather than documenting a convention: the generated code can act on them. RFC 8594 standardises a Sunset HTTP header carrying exactly this date, and the IETF has a companion Deprecation header in draft. A contract that declares a sunset can therefore produce an endpoint that announces it on every response, to every client, without anyone writing that code.
Declared once in the spec, enforced in CI by the doctor, and advertised over HTTP by the generated controller: that is the whole thesis of this package applied to a single field.
Open, four questions rather than one:
- The date format.
x-sunsetshould almost certainly be RFC 3339, converted to the HTTP-date the header requires. - Whether emitting the headers is on by default.
- The warning horizon the emitted header implies, as distinct from the doctor's, which is already configurable.
- The collision risk of a name as generic as
x-lifecycle, which another tool may already define differently. A vendor prefix would remove the ambiguity at the cost of every consumer typing it.
What this document does not cover
Four questions a reader arrives with that are answered elsewhere, or not yet answered at all:
- These keys are not access control.
x-audience: internalsays who an operation is promised to, never who may call it: nothing in the routing or the middleware reads it. Who may call an operation issecurity.md's subject. The one place the key changes an artifact is the sanitized public copy, which leaves internal operations out entirely. - Nothing stops serving a sunset operation.
x-sunsetis a date the doctor holds you to, not a switch. The build still emits the route the day after it passes, and the finding is the whole of the enforcement: removing an endpoint is an edit to the contract, which is the only place that decision belongs. - What counts as a breaking change is not settled here. The table above states the direction; the exhaustive rule set is public API and lands with the phase that enforces it.
- How any of this is printed belongs to the doctor. This document owns the rules and their defaults. Exit codes, flags, the JSON shape and where each finding is grouped are that document's.