Skip to content

API versioning and compatibility ​

The /v1 HTTP surface carries one promise: a client built against it keeps working against every later /v1.x release of the control plane. This page states the promise, then walks the gates that hold the repository to it.

The authoring loop that produces the spec (edit the YAML, lint it, regenerate the artefacts) lives on a separate page, OpenAPI-first contract and code generation. Read that one to change the contract, this one to find out whether the change is allowed.

The policy ​

  • Major version in the path (/v1, /v2, …). The REST surface and the SSE event envelope share a single major version. There is no split between a /v1 REST contract and a differently versioned event contract.
  • Additive changes are not breaking. A new endpoint, a new optional request field, a new response field, a new SSE event type, or a new enum value alongside the existing ones stays inside the current major. Clients must tolerate unknown fields and unknown event types.
  • Breaking changes land in /vN+1. Removing an endpoint or a field, changing a type, tightening validation, renaming an enum value, or reshaping an SSE event payload triggers a new major. The previous major is served in parallel for at least 12 months (SaaS) or for the lifetime of the latest LTS self-hosted release line, whichever is longer.
  • Deprecation is signalled on the wire. An operation scheduled for removal advertises the Deprecation and Sunset response headers, X-Plexsphere-API-Version names the current and the latest major, and the change gets a changelog entry through its commit subject (see Commit subjects and release notes).
  • OpenAPI is the source of truth.api/openapi/plexsphere-v1.yaml is the contract; the prose here is explanatory, not normative. The Go server stubs, the Go client, and the Console TypeScript client are all generated from it.
  • plexd's contract is versioned the same way. A plexd built against /v1 works against every /v1.x control-plane release. Moving plexd onto a new major is a deliberate rollout, not a side effect of upgrading the control plane.

The normative statement is README §API Versioning & Compatibility. Everything below is the tooling that keeps the repository honest about it.

Additive versus breaking changes ​

The table is the working reference for the difference, applied to the shape of this spec.

ChangeClassificationAction inside /v1
Add a new operation (path + operationId).AdditiveLand in /v1; add handler + tests in the same PR.
Add an optional request field or query parameter.AdditiveLand in /v1; server must accept omission as the prior default.
Add a new response field.AdditiveLand in /v1; clients must tolerate unknown fields (see the API-versioning policy in README §API Versioning).
Add a new enum value.AdditiveLand in /v1; clients must tolerate unknown values. Document the semantics in the schema description.
Add a new 4xx/5xx shape (still application/problem+json).AdditiveLand in /v1; keep legacy shapes available until a major bump.
Mark a field or operation as deprecated (no removal yet).AdditiveLand in /v1; set deprecated: true on the operation together with x-plexsphere-deprecated-since and, once a removal date is agreed, x-plexsphere-sunset. The middleware stamps the headers from those markers, see Deprecation and sunset.
Remove an operation, field, or enum value.BreakingLand in /vN+1 only; the /v1 router keeps serving the old contract in parallel for the overlap window.
Rename an operationId, field, or path segment.BreakingSame as removal — the old name is a distinct wire contract.
Tighten validation (narrower regex, smaller max, required → non-null).BreakingSame — previously-accepted inputs would start failing.
Change a type (integer → string, array → object).BreakingSame — the TS paths/operations types no longer compile for unmodified clients.
Change the semantics of a response code for an existing operation.BreakingSame — clients switch on status code and will mis-route.
Change the shape of an SSE event envelope (not in /v1/openapi.json today but governed by the same rule).BreakingTrack alongside the REST major bump; the two share a version.

The guiding intuition: if a well-behaved existing client — one that tolerates unknown fields and unknown enum values — can still round-trip every request/response against the new spec, the change is additive. If any such client would break or silently misinterpret, the change is breaking and must be held for the next major version.

The compatibility gate below is what turns a Breaking row into a red build: it classifies the spec diff and fails the job on any breaking finding the exceptions file does not name.

The compatibility gate ​

The gate lives in tests/workspace/openapi_compat_test.go and holds four tests: TestOpenAPICompat_NoUnlistedBreakingChangesAgainstBase is the gate itself, TestOpenAPICompat_SyntheticPathRemovalIsBreaking proves the classifier is wired up by deleting an operation from a copy of the real spec, TestOpenAPICompat_ExceptionWaivesABreakingFinding proves a waiver written from that finding matches it, and TestOpenAPICompat_ExceptionsFileIsWellFormed pins the shape of the waiver file.

Picking the base. The gate compares the working-tree spec against the copy at git merge-base HEAD <base>, where <base> comes from PLEXSPHERE_OPENAPI_BASE_REF and defaults to origin/main. It is the merge-base rather than the tip of the base branch so that a change someone else landed on the base after your branch point is not read back as a reversed change on your pull request.

Which runs are gated. The gate carries no build tag and lives in a go.work module, so make test fans it out in the unit job as well. That job checks out shallow and names no base ref, so it has neither a base branch to compare against nor a verdict to reach about the pull request; the test skips whenever CI is set and PLEXSPHERE_OPENAPI_BASE_REF is not. Off CI the default applies, so a plain make openapi-compat on a workstation still classifies against origin/main.

Three outcomes on the ref itself:

  • The ref does not resolve to a commit: when the caller named it through PLEXSPHERE_OPENAPI_BASE_REF the test fails, because that caller is the openapi-compat job — a required check, where a skip would report success for a gate that classified nothing. If it ever fires, the checkout stopped fetching the base branch, see fetch-depth: 0 on the job. On the default ref the test skips instead and names the variable in the skip message: a laptop without the remote branch cannot produce a comparison, and a hard failure there would only teach contributors to ignore the gate.
  • The ref resolves but shares no history with HEAD: the test fails. There is no contract to compare against, and a mistyped-but-existing ref must not silently disable the gate.
  • The merge-base commit does not carry the spec file: the test fails. There is nothing to diff the working tree against.

Classifying the diff. The comparison runs through oasdiff as a Go library, with every backward-compatibility check the tool ships. A finding at level ERR is breaking and fails the gate unless the exceptions file lists it. Findings at WARN and INFO never fail; they are logged as the additive summary so a reviewer sees what the pull request does to the contract (adding an optional field lands at INFO). An empty diff logs that there is no contract change.

Running it locally.

bash
# What CI does: classify against the merge-base with the base branch.
git fetch origin main
PLEXSPHERE_OPENAPI_BASE_REF=origin/main make openapi-compat

# Classify only the uncommitted spec edits in the working tree.
PLEXSPHERE_OPENAPI_BASE_REF=HEAD make openapi-compat

make openapi-compat forwards the variable through Make's environment inheritance, so both forms above reach the test unchanged. In CI the work is done by the openapi-compat job, which runs on every pull request with full git history and sets the variable to the pull request's base branch. Its reproduction recipe is in ci.md.

The exceptions file ​

api/openapi/compat-exceptions.yaml is the only way past a breaking finding. Each entry names the oasdiff check id, the upper-case HTTP method, the /v1/... path, the finding's own text, and the reason the team accepted the break:

yaml
exceptions:
  - id: api-path-removed-without-deprecation
    method: GET
    path: /v1/legacy-example
    detail: api path removed without deprecation
    reason: retired mock-era path; no released plexd calls it

All five fields are required, the method has to be upper case, and the path has to start with /v1/. The committed file is exceptions: [] and is meant to stay that way most of the time — a landed waiver is the documented way past a breaking finding, so the well-formedness test checks the shape of the file, never that it is empty.

The detail is what bounds a waiver to the break it was reviewed for. Two removals of different properties on the same operation share a check id, a method and a path; matching on those three alone would let a landed entry keep waiving every later break of that class on that operation, long after the change it named merged. Copy the text verbatim out of the failure message the gate printed.

An entry that matches no finding is judged by where it came from. One the pull request adds fails the gate the same way an unlisted break does: it silences nothing, and the author would otherwise learn that only when the gate fails on the change they believed was waived. One the base branch already carries is spent — the change it waived has merged, so no later comparison produces the finding again — and the gate logs it as removable instead. Failing on it would turn the required check red on every branch that never touched the contract, until someone landed the cleanup.

Reading a red run ​

Every unlisted breaking finding prints one error naming the check id, the operation, the path, oasdiff's own text for the check, and where to go next:

text
breaking change api-path-removed-without-deprecation on GET /v1/legacy-example: api path removed without deprecation
List it in api/openapi/compat-exceptions.yaml with a reason — the entry needs this id, method, path and the text above as its detail — or reshape the change; the policy is documented in docs/contributing/api-versioning.md

There are exactly two ways out, and picking one is a review decision, not a mechanical fix:

  1. Reshape the change so it stops being breaking. Add the new field instead of renaming the old one, keep the removed operation answering, widen the validation back. This is the default.
  2. List it in the exceptions file with a reason a reviewer can check. The reason has to say why the break is acceptable, for example that no released plexd calls the path.

An unmatched-exception failure has one fix: correct the id, method, path and detail against the finding the entry is meant to waive, or delete the entry.

Deprecation and sunset ​

A deprecation is declared in the OpenAPI document and nowhere else. The operation object carries three markers:

  • deprecated: true, the OpenAPI flag itself.
  • x-plexsphere-deprecated-since, an RFC 3339 instant naming when the deprecation was published. Required whenever deprecated: true is set: without an instant there is no header to emit, so the declaration is rejected instead of degrading to a silent no-op.
  • x-plexsphere-sunset, an RFC 3339 instant naming when the operation is scheduled for removal. Optional, because an operation may be deprecated long before a removal date is agreed. When present it may not be earlier than the since instant.

Either extension without deprecated: true is an error. Such an operation advertises a deprecation to spec readers that no client ever sees on the wire.

yaml
paths:
  /v1/version:
    get:
      operationId: GetVersion
      deprecated: true
      x-plexsphere-deprecated-since: "2026-04-01T00:00:00Z"
      x-plexsphere-sunset: "2027-04-01T00:00:00Z"

Every response from that operation then carries:

http
X-Plexsphere-API-Version: current=v1, latest=v1
Deprecation: @1775001600
Sunset: Thu, 01 Apr 2027 00:00:00 GMT

How the markers reach the wire.DeprecationsFromSpec reads the markers out of the document and Deprecations turns the result into a middleware that stamps the two headers on the listed operations. The encoders are SetDeprecation and SetSunset: Deprecation uses the RFC 9745 structured-field Date form (@<unix-seconds>), Sunset the RFC 8594 IMF-fixdate form. The composition root derives the table from the embedded spec at boot and refuses to start on a malformed marker, with the parse error prefixed api deprecations:.

The headers reach 401 and 403 too. In the /v1 chain the deprecation middleware sits right after the version header and before authentication, so the order is version header, deprecation, authn, authz, CSRF. A client polling a deprecated operation with an expired credential still learns the removal date from the 401 it gets back. Both headers are written before the handler runs, so an early WriteHeader cannot drop them.

Nothing changes at the sunset instant. The middleware consults no clock. Past the declared removal date the operation keeps answering, still carrying both headers. Removal is an edit to the spec, which the compatibility gate classifies as breaking and which therefore needs an exceptions entry and a reviewer. The alternative, answering 410 Gone once the instant passes, would make the wire behaviour depend on the server clock: a skewed replica would remove an operation its peers still serve, without the spec edit the gate exists to review.

Property-level deprecated emits nothing. A deprecated: true on a schema property is documentation for spec readers and renders in the API docs; the walk stops at the operation object and never descends into schemas, so no header follows from it.

Not the same signal. POST /v1/auth/tokens/{id}/rotate also returns a Sunset header. That one names when the rotated-from credential stops working, a per-credential lifecycle fact, and it is unrelated to the lifecycle of the API contract.

What guards the markers. Three Spectral rules in tools/openapi/.spectral.yaml, all at severity: error, so make openapi-lint fails on a malformed declaration:

RuleRejects
plexsphere-deprecated-operation-declares-sincedeprecated: true without x-plexsphere-deprecated-since.
plexsphere-deprecation-marker-requires-deprecatedEither extension on an operation that is not marked deprecated: true.
plexsphere-deprecation-dates-are-rfc3339An instant that is not an RFC 3339 date-time.

The first two have negative fixtures that tests/workspace/openapi_lint_test.go runs the linter against: tools/openapi/__fixtures__/deprecated-without-since.yaml and tools/openapi/__fixtures__/sunset-without-deprecated.yaml.

The parser and the middleware are covered by deprecations_test.go, the chain order by TestMount_Deprecation_ChainOrder in router_test.go, and the spec-to-wire cycle by apiversion_deprecation_cycle_test.go.

The version header ​

Every /v1 response carries:

http
X-Plexsphere-API-Version: current=v1, latest=v1

It comes from apiversion.Middleware and is independent of any deprecation: an operation nobody deprecated carries it too. current is the major the request was served under, latest the newest major the deployment serves. The two differ once a /v2 exists, which is how a client on the old major learns a newer one is available without polling the spec.

Client tolerance ​

The compatibility promise is one-directional: an old client against a new server. It rests on four rules every consumer of the API has to follow.

  • Tolerate unknown response fields.
  • Tolerate unknown enum values.
  • Tolerate unknown SSE event types.
  • Tolerate unknown SSE field lines.

In-repo consumers are held to the rules by tests rather than by convention:

  • pkg/openapi/v1/client/forward_compat_test.go covers the generated Go client with TestClient_ToleratesUnknownResponseFields, TestClient_ToleratesUnknownEnumValues and TestClient_ToleratesUnknownProblemFields. The client decodes with encoding/json, which drops unknown members, and string-typed enums accept any value.
  • internal/platform/testutil/sse/capture_unit_test.go covers the test-side SSE capture with TestCapture_PassesThroughUnknownEventType: it ignores unknown field lines and applies no event-type allowlist.
  • cmd/sse-stub-plexd/main_test.go covers the fixture agent with TestConsumeStream_UnknownEventTypePassesWithoutAllowlist and TestConsumeStream_UnknownEventTypeRejectedOnlyByAllowlist: an unlisted event type is rejected only when a fixture opts into an allowlist.

Tolerance does not run the other way. Server-side request-body decoders keep DisallowUnknownFields, because the promise is an old client against a new server: a request field the server does not know is a client bug or a typo, and refusing it at the boundary is cheaper than accepting it and ignoring it.

The Console TypeScript client goes through openapi-fetch, which does no runtime schema validation, so there is nothing in it that could reject an unknown member. It carries no tolerance test for that reason.

The old-plexd scenario ​

The client-tolerance tests pin the decoder behaviour. The plexd-compat e2e scenario proves the promise end to end: a real agent release built against /v1 drives the full journey against a control plane built from the current tree.

The golden-flow suite cannot prove it. Golden-flow runs the agent pinned in /PLEXD_VERSION, which is the release the tree currently targets, so that agent stops being old the day the pin moves.

The N-1 pin. /PLEXD_COMPAT_VERSION and /PLEXD_COMPAT_DIGEST hold the release immediately below the main pin, in the same format and resolved the same way. The rule is N-1: whenever /PLEXD_VERSION moves, the compat pair takes the values the main pair had. TestPlexdCompatVersionFile_IsOlderSemVerThanPin in tests/workspace/plexd_compat_version_test.go keeps the compat pin strictly below the main one, so the two cannot quietly converge.

Materialising the image. make docker-build-plexd-compat pulls the pinned release and retags it as plexd:<compat version>. It re-invokes the docker-build-plexd recipe with both pins overridden, so the pull, the registry drift check and the retag stay defined once.

The overlay.deploy/local/overlays/plexd-compat renders exactly what the dev overlay renders, except that both plexd Deployments schedule the compat tag. The control plane, the migrations and the seed Jobs all come from the current tree.

The journey.tests/e2e/upgrade/plexd-compat/chainsaw-test.yaml runs the golden-flow agent journey on that stack: registration, node state, heartbeat to healthy, and an operator key rotation delivered over signed SSE. Two steps are specific to this suite:

  • assert-compat-image-scheduled reads the scheduled image off both plexd Deployments and fails unless it is the compat tag. Every way of getting the overlay wrong (a stale newTag, a dropped resource, an image transformer that stops matching the name) renders the dev overlay verbatim, which would run the main pin under a compat name and stay green. The pre-check is what makes the rest of the suite mean something.
  • assert-api-version-header performs an in-cluster GET /v1/version and requires X-Plexsphere-API-Version: current=v1, latest=v1 on the response, so the header the policy above promises is checked against a running deployment rather than a unit test.

The suite runs nightly next to golden-flow, in the same e2e-full matrix. The operator guide, including how to run it against an existing kind cluster, is tests/e2e/upgrade/README.md.