In [rfd421] and [rfd532], we introduced versioning for our Dropshot HTTP APIs in service of automated update for the Oxide system software [rfd418]. This RFD provides prescriptive guidance for how to organize published types in a way that minimizes burdens and remains consistent over time.
Motivation goes over the reasons this guide is necessary.
Principles contains the general rules around which this RFD is based.
Determinations describes the proposed steady state of code organization.
Guides has instructions on how to get to and stay in this steady state.
Rationale describes the reasoning behind this RFD’s determinations.
Glossary
| Term | Definition | Example |
|---|---|---|
API crate | A Rust crate which defines a Dropshot API trait [rfd479], the name of the crate typically ending in | |
Server-side-versioned API | A Dropshot API which can understand multiple client versions [rfd532]. | The Sled Agent API |
Published type | A type that is (possibly transitively) part of an API trait, and published in the corresponding OpenAPI document. | |
Types crate | A Rust crate that defines a common vocabulary of types for business logic to use, the name of the crate typically ending in | |
Versions crate | Introduced in this RFD, a crate that defines previous and current versions of published types, along with code to convert between versions of these types. | |
Client crate | A crate that implements a strongly-typed HTTP client for an API using Progenitor. | |
Versioned identifier | A path to a versioned type that will remain stable over time, meant for use within type conversion-related code. |
|
Floating identifier | A re-export of a versioned type that may change over time, akin to a symlink. Meant for use within regular business logic. |
|
Motivation
As part of self-service update, the Dropshot APIs that are part of the Omicron control plane were converted over to being server-side-versioned during the period August to October 2025. As we’ve gained experience with organizing types for each server versions, we’ve noticed that the process of updating existing published types can be quite burdensome.
For example, consider omicron#9389. This commit added version 10 of the Sled Agent API (VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES). As part of this pull request, a number of changes were performed:
Existing types had to be copied into a new file,
sled-agent/.types/ src/ inventory/ v9.rs The modules containing types for old versions were in locations like
sled-agent/. Because theapi/ src/ v3.rs v9types had to be available outside of the Sled Agent API, they were put intosled-agent/. The different locations for each set of types adds to confusion.types Since they defined conversions to the types that were now in
v9.rs, prior version modules had to be updated extensively. For example,sled-agent/saw a number of updates.api/ src/ v3.rs
Also, a lack of instructions has led to some confusion in the past. For example, omicron#9091 had to be followed up with omicron#9407.
This RFD aims to rectify the above issues through clear guidance, along with instructions for a one-time migration suitable for use for both humans and (because this is a complex, tedious process) LLMs [rfd576].
Principles
Code organization should be intuitive. Existing type conversions should also act as examples for future versions, so that developers do not have to read this RFD to figure out how to make changes.
The burden of changing a published type should be proportional to the number of affected types. Developers should not have to alter prior version modules or unrelated types.
Conversion code should be self-contained. Conversions between different API versions' types should be handled at API boundaries. Regular business logic shouldn’t need to care about them.
All types corresponding to a particular API version must live in one or more dedicated modules. If the same type is present in different forms across multiple API versions, they should not be mixed together.
Types that are only published by one API should indicate the first API version they’re associated with. With quick inspection, developers should be able to tell which API version a type was introduced in.
Type conversions should generally be semantically immutable. Adding a new version of a type should not have to require altering older version modules. This matches the immutability of blessed API versions and database migration code. (We allow for non-conversion-related changes such as changes to inherent methods, Rust formatting, and Clippy fixes.)
There must be a process for removing old, newly-unsupported API versions. As we stop supporting older API versions based on our support policy [rfd594], we’ll want to stop supporting old API versions. Cleaning up newly-unused types should be a relatively straightforward process.
Determinations
Overview
Consider the Sled Agent API. Today, for a given type X published in the Sled Agent API:
With the proposal in this RFD:
All versions of
Xare defined in a new versions crate named something likesled-agent-types-versions.sled_agent_types::Xbecomes a re-export for the latest version ofXdefined insled-agent-types-versions, saysled_agent_types_versions::vN::X.If someone changes X in a new version M, they’ll create
sled_agent_types_versions::vM::Xand change thesled_agent_types::Xre-export to point tosled_agent_types_versions::vM::X.The definition of
sled_agent_types_versions::vN::Xdoes not change.
The rest of the determinations section works with this example.
The following diagram shows an archetypal dependency graph, with two implementations of an API trait as well as the proposed versions crate:
Key points:
The versions crate is the source of truth for all published types.
The types crate is a facade that re-exports from
latest, used by business logic.The API trait depends only on the versions crate (not the types crate).
Business logic depends only on the types crate, not the versions crate.
Boundary code depends on the versions crate for prior version endpoints.
Every types crate has a corresponding versions crate
Currently, every server-side versioned API has an associated types crate:
The Sled Agent API’s corresponding crate is
sled-agent-types.The Nexus external API’s corresponding crate is
nexus-types. (Note that the Nexus internal API is frozen, so the only server-side versioned API associated withnexus-typesis the Nexus external API.)
There are also other, more central types crates, such as omicron-common.
This RFD proposes that each types crate also has an associated versions crate. The name of the versions crate is the name of the types crate, with -versions appended at the end.
For example:
sled-agent-typeshas a correspondingsled-agent-types-versionscrate.omicron-commonhas a correspondingomicron-common-versionscrate.
Also, each types crate depends on the corresponding versions crate.
Special note: In some cases we permit other types crates to depend on versions crates, if they’re helpful at breaking a circular dependency. For example, nexus-types is permitted to depend on sled-agent-types-versions for its types.
Every published type is defined in a versions crate
In particular, types published by exactly one API live in the API’s corresponding versions crate. Continuing with our overview example, all versions of X live in sled-agent-types-versions.
Some corollaries:
Prior to this work, the types crate contains the latest versions of published types. With this determination, the current types crates will become facades for the corresponding versions crate, as far as published types are concerned. (Non-published types will continue to be defined in the types crate.)
The current API crates contain many higher-level types (particularly request and response types). These types will also be moved to the corresponding versions crate.
Versions crates have top-level vN modules
For example, a versions crate may have top-level modules named v1, v2, v3 and so on.
For versions crates associated with exactly one API, such as
gateway-types-versions, N matches corresponding API versions exactly. For most APIs at Oxide, N is an incrementing integer, but in cases where it’s not, such as the Nexus external API (which uses date-based versioning, e.g. 2025122500), N still matches the corresponding API version exactly (v2025122500).For versions crates associated with more than one closely-related API, such as
sled-agent-types(which provides types for both the Sled Agent and Bootstrap Agent APIs), there arevNmodules for the Sled Agent API, andbootstrap_vNmodules for the Bootstrap Agent API.For shared types crates, N is an incrementing integer independent of API versions, to be bumped whenever a shared published type changes.
The top-level modules always have submodules, and later versions mirror those submodules. For example, sled_agent_types_versions::v1 may have bootstore, disk, and inventory submodules. Even if v2 only adds or changes types in bootstore, the corresponding type conversions are present in sled_agent_types_versions::v2::bootstore (and not sled_agent_types_versions::v2).
Version modules are stored in named subdirectories
Version modules are always directories, not files. They are not stored as versions/. Rather, they are stored as versions/, where version_name is the lowercase form of the named version identifier used in the api_versions! macro. They are, however, referred to as sled_agent_types_versions::vN, using a #[path] attribute.
For example, if the Sled Agent API has the following versions:
api_versions!([
(2, MULTICAST_SUPPORT),
(1, INITIAL),
]);Then, the versions crate’s lib.rs has:
#[path = "initial/mod.rs"]
pub mod v1;
#[path = "multicast_support/mod.rs"]
pub mod v2;and types corresponding to v1 and v2 live in the initial/ and multicast_support/ directories, respectively.
pub mod vN declarations must not have blank lines in between them. This makes rustfmt have a consistent sort order for them, ensuring that with colliding changes, merge conflicts are always produced in lib.rs.Each published type is defined in the earliest version it is present in
Continuing with our overview example: if X was first introduced in version 3 of the Sled Agent API, its canonical definition lives in sled_agent_types_versions::v3::X. If X is later cha nged in version 5, the new version lives in sled_agent_types_versions::v5::X, while the original remains in v3.
v3::X and v5::X, not a single module with X_V3 and X_V5.We call the sled_agent_types_versions::vN:: identifier the versioned identifier of a path. The shape of the types identified by these identifiers stays the same over time.
Each version module contains code to convert from/to one prior version
For request-only types, we define a type conversion from the prior version of the type to the new one.
For response-only types, we define a type conversion from the new version of the type to the previous one.
For types used in both requests and responses, we define conversions both ways.
Here, "prior version" is per-type. For example, if version 5 of an API changes a request-only type, and the last time the type was changed was in version 2, then the v5 module should contain the code that migrates from version 2 (referred to via crate::v2).
In most cases this can be done via a From or TryFrom implementation, of the form:
pub struct MyType {
// ...
}
// For request types:
impl From<crate::v3::MyType> for MyType {
fn from(old: crate::v3::MyType) -> Self {
// ...
}
}
// For response types:
impl From<MyType> for crate::v3::MyType {
fn from(new: MyType) -> Self {
// ...
}
}It is okay if a From or TryFrom implementation cannot be provided, for instance if ancillary data is required. In that case, conversions are defined through from_vN and into_vN methods.
pub struct MyType {
// ...
}
impl MyType {
// For request types:
fn from_v3(old: crate::v3::MyType, extra_address: IpAddr) -> Self {
// ...
}
// For response types:
fn into_v3(self, extra_port: u16) -> crate::v3::MyType {
// ...
}
}Most of the time, conversions from/to even older versions are not necessary. (Instead, hop through intermediate versions.) The exception is if the series of conversions is too expensive for some reason—appropriate judgment should be used here.
Version modules only refer to versioned identifiers in prior versions
Consider a sled_agent_types_versions::v7 module. This module would only refer to sled_agent_types_versions::v6 and earlier versions, and not to v8 or later versions. If a type in v7 changes in v8, conversion code would live in the v8 module, not the v7 module.
Version modules should never refer to re-exports (floating identifiers), including in common crates. So, in sled-agent-types-versions, use omicron_common_versions::v1::internal::shared::NetworkInterface, not omicron_common::api::internal::shared::NetworkInterface.
Also, version modules should not re-export unchanged types from prior versions. The goal of this determination is to only have one versioned identifier for a type.
Versions crates re-export the latest versions of each type
Versions crates re-export the latest versions of each type, under a top-level latest.rs module. Continuing with our overview example, sled-agent-types-versions would have a latest.rs that re-exports the newest version of each type, including X. For example:
pub mod inventory {
pub use crate::v1::inventory::Baseboard;
pub use crate::v1::inventory::BootImageHeader;
// ...
pub use crate::v10::inventory::ConfigReconcilerInventory;
pub use crate::v10::inventory::ConfigReconcilerInventoryStatus;
// ...
}
pub mod probes {
pub use crate::v10::probes::ExternalIp;
pub use crate::v10::probes::IpKind;
pub use crate::v10::probes::ProbeCreate;
pub use crate::v10::probes::ProbeSet;
}
// ...The re-exports in the latest module:
within each module, are grouped together by version—so all
v1re-exports in one group, allv2re-exports in another group, and so on, where groups are in ascending order by version, separated by blank lines; andnever use wildcards (
*) for re-exports, instead always specifying each re-export by name.
Functional code attached to types resides in an impls module
Functional code attached to types, defined as code not directly required by conversions, might be defined as inherent methods or external trait implementations (e.g. Display, FromStr, Ledgerable) on versioned types. In general, such code must always be implemented on the latest versions of each type. All such code should live in the impls module within the versions crate.
Functional code includes:
Inherent methods
Display,FromStr,Ledgerable, and other implementations of foreign traitsOther custom helpers accessed via inherent methods (e.g. custom displayers)
Do not move code that is inherent to the versioned nature of the type, such as serialization and conversion code:
JsonSchema,Serialize,DeserializeDebug, since having debugging output for prior versions can be quite usefulMethods on older versions used by business logic
Other code used as part of these implementations
If in doubt, bias towards treating code as functional and putting it in the impls module. Getting this wrong is not too costly, since code can be moved from impls to version modules or vice versa.
The impls module is private to the crate:
mod impls;
pub mod latest;
#[path = "initial/mod.rs"]
pub mod v1;
// ...The impls module is a directory with a mirrored module structure:
// (License header here)
//! Functional code for the latest versions of types.
mod config;
mod user;
// ...Within the impls module, types are referred to using latest:: identifiers.
Types crates use wildcard re-exports from the latest module
Each types crate mirrors the module structure from the versions crate, and does wildcard re-exports from the latest module. For example, in sled-agent/:
pub use sled_agent_types_versions::latest::inventory::*;These re-exports allow for business logic to not have to depend on sled-agent-types-versions at all.
Within API traits, the latest versions use floating identifiers
The API trait depends only on the versions crate, not the types crate.
Continuing with our overview example: when the Sled Agent API trait references X in the latest version of an endpoint, it uses latest::X: a floating identifier that points to whichever version of X is current.
For the latest versions of each API:
Always use floating identifiers from the
latestmodule, with identifiers referred to usinglatest::paths.Do not suffix endpoint names with a version number.
For an example, see [determinations-api-previous-fixed] below.
Within API traits, prior versions use versioned identifiers
Continuing with our overview example: if X was introduced in version 3 and changed in version 5, the Sled Agent API trait would have an endpoint my_endpoint using latest::X, and an endpoint my_endpoint_v3 using v3::X.
Prior versions of endpoints are defined in descending order, use versioned identifiers, and suffix endpoint function names with the first version they were introduced in. As a corollary, prior versions need to set the operation_id in the dropshot::endpoint macro.
Where possible, API endpoints from prior versions should use a provided method on the trait, with a default implementation that forwards to the newer version. This method should:
Convert arguments to the newer version as appropriate, using
maportry_map.Invoke the newer version using
Self::syntax.Convert responses back to the old version as appropriate, using
maportry_map.
Sometimes, particularly if type conversions require ancillary data from Self::Context, this may not be possible. In these situations, we allow prior version endpoints to be required methods.
For prior versions, we prescribe exactly the following scheme, with identifiers referred to by vN:: paths.
Example versioned API trait
use sled_agent_types_versions::{latest, v3, v5};
#[dropshot::api_description]
pub trait SledAgentApi {
type Context;
#[endpoint { .. }]
async fn my_endpoint(
rqctx: RequestContext<Self::Context>,
path: Path<latest::my_component::MyPath>,
) -> Result<HttpResponseOk<latest::my_component::MyResponse>, HttpError>;
#[endpoint { .. }]
async fn my_endpoint_v5(
rqctx: RequestContext<Self::Context>,
path: Path<v5::my_component::MyPath>,
) -> Result<HttpResponseOk<v5::my_component::MyResponse>, HttpError> {
// In this case, assume that there's a
// `From<v5::my_component::MyPath> for latest::my_component::MyPath`,
// and `From<latest::my_component::MyResponse> for
// v5::my_component::MyResponse`. This allows my_endpoint_v5 to be a
// provided method.
Self::my_endpoint(rqctx, path.map(latest::my_component::MyPath::from))
.await?
.map(v5::my_component::MyResponse::from)
}
// If converting from v3::my_component::MyPath to
// v5::my_component::MyPath requires extra information not already
// available on v5, make `my_endpoint_v3` a required method (do not
// provide an implementation), and add a comment explaining why.
#[endpoint { .. }]
async fn my_endpoint_v3(
rqctx: RequestContext<Self::Context>,
path: Path<v3::my_component::MyPath>,
) -> Result<HttpResponseOk<v3::my_component::MyResponse>, HttpError>;
}API implementation identifiers match trait identifiers
Continuing with our overview example: when the Sled Agent implements its API trait, the implementation signatures must use the same floating or versioned identifiers as the trait.
API implementations match the corresponding traits:
For latest versions, floating identifiers are used. Within implementations, however, we allow types to be directly imported by name from the types crate. (This makes it easier to refer to the types within the implementation.)
For prior versions that are required methods on the trait, fixed identifiers (
vN::paths) are used.
Example versioned API implementation
use sled_agent_types::my_component::{MyPath, MyResponse};
use sled_agent_types_versions::v3;
enum SledAgentApiImpl {}
impl SledAgentApi for SledAgentApiImpl {
type Context = /* ... */;
async fn my_endpoint(
rqctx: RequestContext<Self::Context>,
path: Path<MyPath>,
) -> Result<HttpResponseOk<MyResponse>, HttpError> {
/* ... */
}
// Since my_endpoint_v5 already has a default implementation, do not
// implement it here.
// my_endpoint_v3 is a required method, so implement it here.
async fn my_endpoint_v3(
rqctx: RequestContext<Self::Context>,
path: Path<v3::my_component::MyPath>,
) -> Result<HttpResponseOk<v3::my_component::MyResponse>, HttpError> {
/* ... */
}
}In client crates, replace statements use identifiers matching the corresponding client version
For server-side versioned APIs, client crates are defined against the -latest.json symlinks. Within these clients, replace statements should use floating identifiers from the latest module of the versions crate.
In the future, client-side versioned APIs [rfd567] may need to have additional client crates based on fixed versions of OpenAPI documents. Within these clients, replace statements would use versioned identifiers.
One-time migration to this RFD’s scheme
This RFD proposes a one-time migration to the scheme described in the determinations above. Each types crate is converted one at a time to the new style. This is as opposed to the alternative where individual types are slowly reorganized towards the direction described by this RFD.
Guides
Initial migration
This section describes how to migrate to the scheme described by the RFD, per [determinations-one-time-migration]. In general, it is recommended that one types crate is migrated to this new scheme at a time in its own refactor-only change. We’ve had some success using Claude Opus 4.5 (see [rfd576]) to ease the migration process, feeding this document in as a guide. (There’s an "instructions for LLMs" section above that’s not rendered on the RFD site.)
Some examples, in increasing order of complexity:
omicron#9483: reorganize dns-server types
omicron#9487: reorganize gateway-types
omicron#9488: reorganize sled-agent-types
Create types and versions crates if they don’t exist already
As described in [determinations-versions-crate], create the appropriate types and versions crates. Note that each API-specific types crate (e.g. sled-agent-types) and each shared types crate (e.g. omicron-common) gets a corresponding versions crate.
Follow all the general rules for creating crates in that workspace:
Determine the path on disk for each crate.
Typically, the versions crate should be a subdirectory of the types crate—for example,
sled-agent-typesis present atsled-agent/. Addtypes/ Cargo.toml sled-agent-types-versionstosled-agent/. But if a workspace follows a different style (e.g. a single flat list undertypes/ versions/ Cargo.toml crates/), follow that pattern.* Add to
workspace.membersandworkspace.default-membersin the rootCargo.toml. (No need to do this if the path is already covered by a wildcard.)Add the crate to
workspace.dependenciesin the rootCargo.tomlso that other crates can depend on it.Add a dependency on the
workspace-hackcrate, if the workspace has one.Add a dependency from the types crate to the versions crate.
Enumerate all published types recursively
Determine the first version of the API each type was introduced in. Use the API crate (e.g. sled-agent-api/) as the source of truth. If no version is specified or the type predates versions, assume v1. Check versioned OpenAPI documents (e.g. openapi/) if in doubt.
Prior versions of types may either be present in the API crate (e.g. sled-agent/) or in an existing types crate. In both cases, all types move to the versions crate (making types public as necessary). Note, however, that current organization may have incorrect numbering for types: for example, sled-agent/ defines the Inventory type used from version 1 through 3. Our determination is that types should live in the first version they were defined in, not the last version they were used in. Consulting the Sled Agent API, one sees that this inventory type was part of API versions 1 through 3, it should be moved to v1::inventory, not v3::inventory.
For shared types, use an incrementing integer not specifically tied to an API version. For example, for types in omicron-common, use v1, v2, and so on in chronological order. Add a comment in v1/ explaining which initial versions of downstream APIs this corresponds to.
Create version modules for each API version with added or changed types
For each version that adds or changes types, define a version module. For API-specific types crates, use the same version number as the API version. For shared/common crates, use an incrementing integer.
Store version modules at paths corresponding to named versions from the api_versions! macro. Always use directories (e.g. add_config_endpoint/) for each version module rather than files (e.g. add_config_endpoint.rs).
For example, let’s say that for an API the versions are:
api_versions!([
(2, ADD_CONFIG_ENDPOINT),
(1, INITIAL),
])Then, create:
initial/for types added in version 1mod.rs add_config_endpoint/for types added in version 2mod.rs
Also create a latest.rs module for re-exports of the latest versions of types.
Make lib.rs refer to the version modules thus, adding a comment like the one listed:
// (License header here)
//! Versioned types for the <name of API>.
//!
//! # Adding a new API version
//!
//! When adding a new API version N with added or changed types:
//!
//! 1. Create <version_name>/mod.rs, where <version_name> is the lowercase
//! form of the new version's identifier, as defined in the API trait's
//! `api_versions!` macro.
//!
//! 2. Add to the end of this list:
//!
//! ```rust,ignore
//! #[path = "<version_name>/mod.rs"]
//! pub mod vN;
//! ```
//!
//! 3. Add your types to the new module, mirroring the module structure from
//! earlier versions.
//!
//! 4. Update `latest.rs` with new and updated types from the new version.
//!
//! For more information, see the [detailed guide] and [RFD 619].
//!
//! [detailed guide]: https://github.com/oxidecomputer/dropshot-api-manager/blob/main/guides/new-version.md
//! [RFD 619]: https://rfd.shared.oxide.computer/rfd/619
pub mod latest;
#[path = "initial/mod.rs"]
pub mod v1;
#[path = "add_config_endpoint/mod.rs"]
pub mod v2;Ensure there are no blank lines between pub mod vN declarations. This will cause rustfmt to sort the version numbers in a consistent order.
In case of directories, avoid putting anything other than pub mod statements in mod.rs itself.
Update each version module
Update each version module’s mod.rs file to look something like this, ensuring that <VERSION_NAME> is the named version identifier and not the numeric version. (Using the named version consistently ensures that in case of merge conflicts, the doc comment doesn’t fall out of date.)
// (License header here)
//! Version `<VERSION_NAME>` of <name of API>.
//!
//! (Add a brief summary of what was added or changed in this version. Don't
//! refer to future versions here, just past ones.)
pub mod config;
pub mod user;
// ...Also, within each version module, add submodules for types added or changed in that version. For example, types inside sled-agent/ should go into the corresponding <version_name>/.
Within each submodule:
For type names that are not defined locally and are in prior versions, use fixed identifiers:
use crate::v1::user::UserParam;For type names that are defined locally and are in prior versions, import
crate::vNand usevN::paths to identifiers.use crate::v1;
pub struct UserData {
// ...
}
impl From<v1::user::UserData> for UserData {
// ...
}For type names from the same version, import them via
super, notcrate::vN.use super::config::ConfigData;
pub struct UserData {
config: ConfigData,
}
Also, put high-level request and response types that currently live in the API crate into (existing or new) submodules corresponding to their function. Do not use params.rs, views.rs, or shared.rs; rather, arrange them based on their semantics.
Don’t create these modules if an API version does not have new types of any particular kind.
vN modules. The vN modules should only contain and export types added or changed in that particular version.Re-export latest versions in the latest module
Follow the pattern described in [determinations-reexports], creating a my-versions/ module. Remember to not use wildcard (*) re-exports. Instead, enumerate types explicitly.
Re-export types from latest into the types crate
Follow the instructions in [determinations-types-latest], using wildcard re-exports from the corresponding modules in latest.
Regular business logic does not need to care about versioned identifiers, so it should not have a dependency on the versions crate at all. Instead, it should use the re-exports defined in the types crate. The exception is code dealing with type conversions outside of the OpenAPI/Dropshot context, such as updating JSON documents stored on disk—such code may need to depend on the versioned crate directly.
Move functional code to impls module
Functional code attached to types, here defined as code not directly required by conversions, might be defined as inherent methods or external trait implementations (e.g. Display, FromStr, Ledgerable) on versioned types. In general, such code must always be implemented on the latest versions of each type. Identify all such code, and move it to an impls module within the versions crate.
Functional code includes:
Inherent methods
Display,FromStr,Ledgerable, and other implementations of foreign traitsOther custom helpers accessed via inherent methods (e.g. custom displayers).
The impls module is private to the crate:
mod impls;
pub mod latest;
#[path = "initial/mod.rs"]
pub mod v1;
// ...Always use an impls directory with a mirrored module structure. Here’s a template for impls/:
// (License header here)
//! Functional code for the latest versions of types.
mod config;
mod user;
// ...Within the impls module, always refer to types using floating latest:: identifiers.
As part of the move, if you need access to a private field:
Consider whether it should be private at all. Fields are typically private for encapsulation so data invariants are upheld. But if the serde deserializer for that type does not uphold those invariants—either through a custom
Deserializeimplementation, or through#[serde(try_from = "FromType")]—then making that field private has no use. Make itpub.If the deserializer does uphold invariants, then make the fields
pub(crate).
For custom types like displayers declared in the impls module, export them via the latest module, in a whitespace-separated block after all versions. For example, if a ConfigParseError type is in impls:
pub mod config {
pub use crate::v1::config::ConfigParam;
// ...
pub use crate::impls::config::ConfigParseError;
}Update the API trait
Per [determinations-api-latest-floating], for the latest versions of endpoints, floating identifiers from
latest.Per [determinations-api-previous-fixed], for prior versions of endpoints, including removed endpoints, versioned identifiers from
vN.
In the API crate, import the corresponding versions crate’s latest and vN modules, and refer to types as latest::path::to::MyType or vN::path::to::MyType. For example:
use my_types_versions::{latest, v5};
pub trait MyApi {
type Context;
#[endpoint { .. }]
async fn my_endpoint(
rqctx: RequestContext<Self::Context>,
path: Path<latest::my_component::MyPath>,
) -> Result<
HttpResponseOk<latest::my_component::MyResponse>,
HttpError,
>;
#[endpoint { .. }]
async fn my_endpoint_v5(
rqctx: RequestContext<Self::Context>,
path: Path<v5::my_component::MyPath>,
) -> Result<HttpResponseOk<v5::my_component::MyResponse>, HttpError>;
}Also, ensure that:
Prior versions' endpoint names, including removed endpoint names, are always of the form
endpoint_name_vN.Prior versions have an
operation_idset toendpoint_name.Endpoint versions are in descending order, with the latest version of the endpoint first.
If possible (particularly if conversions only use From or TryFrom), make the prior versions provided methods on the trait, with default implementations which forward to the corresponding latest versions. See [example-api-trait].
If prior versions cannot be expressed in terms of the latest version, make them required methods on the trait, and add a comment explaining why.
Remove dependency from API crate to types crate
Since all published types are now part of the versions crate, there should generally be no need for the API crate to depend on the types crate. Verify that there’s no need for this dependency—if that is the case, remove the dependency:
[dependencies]
# ...
my-types.workspace = true # <-- remove this line
my-types-versions.workspace = true
# ...Update API implementations
Update API implementations (typically in files named http_entrypoints.rs) in a way similar to the trait.
For the latest versions of endpoints, use floating identifiers by name, imported through the types crate. Do not use
latest::paths in endpoint signatures, since they add noise.For prior versions of endpoints, use
vN::paths matching the API trait. Do not import types by name.
use my_types::my_component::{MyPath, MyResponse};
use my_types_versions::latest;
enum MyApiImpl {}
impl MyApi for MyApiImpl {
type Context = ();
#[endpoint { .. }]
async fn my_endpoint(
rqctx: RequestContext<Self::Context>,
path: Path<MyPath>,
) -> Result<HttpResponseOk<MyResponse>, HttpError> {
/* ... */
}
}If a prior version is turned into a provided method, remove it from all implementations.
Update replace statements in client crates
Progenitor replace statements in client crates should use the latest re-exports in the versions crate. Update Progenitor clients to:
Depend on the versions crate
Use
latestre-exportsRemove the dependency on the types crate
Perform cleanup
Since types crates now act as facades for the latest versions, they should no longer define versions modules of their own. For example, internal_dns_types::v1 and v2 should no longer exist.
Generally, most dependencies from the types crate can also be cleaned up. Find unused dependencies and remove them as appropriate.
The nexus-sled-agent-shared crate contains definitions for Inventory as a means to break a circular dependency between sled-agent-types and nexus-types, even though Inventory is only published by the Sled Agent API. [determinations-versions-crate] above describes how the versions crate can be used to break circular dependencies instead. So once the Sled Agent API has been reorganized, nexus-sled-agent-shared should no longer be necessary.
Run cargo xtask openapi check to ensure no APIs have changed
The process described here does not contain any functional changes, so cargo xtask openapi check should exit with success.
Adding a new API version
This workflow is modeled after the lockstep one, but it’s a little trickier because of the considerations around online update. Check out the Dropshot API Versioning docs for important background.
A new API version can add, change, and remove any number of endpoints. This guide covers all three cases.
Overview
At a high level, the process is:
Pick a new version number (the next unused integer) and an identifier in the
api_versions!call for your API. Among other things, theapi_versions!call turns these identifiers into named constants (e.g.(2, MY_CHANGE)defines a constantVERSION_MY_CHANGE).Make your API changes, preserving the behavior of previous versions. (For examples, see Dropshot’s versioning example.)
Adding an endpoint: Use
versions = VERSION_MY_CHANGE..(meaning "introduced in version `VERSION_MY_CHANGE`").Removing an endpoint: Use
versions = ..VERSION_MY_CHANGE(meaning "removed in versionVERSION_MY_CHANGE`"). If the endpoint was previously introduced in some other version, use `versions = VERSION_OTHER..VERSION_MY_CHANGE.Changing arguments or return type: Treat this as a remove + add. Do not change the existing endpoint’s types. Mark it as removed in the new version, define new types for the new version, and add a new endpoint using the new types.
Update the server(s) (the trait impl) and/or the client. Run
cargo xtask openapi generateto regenerate OpenAPI documents.Repeat steps 2-3 as needed, but do not repeat step 1 as you iterate.
Detailed guide
This part of the guide is more opinionated, particularly around type organization. Within Oxide, be sure to follow this guide. See RFD 619 for more information.
Worked example
For the detailed guide, we’ll work with a concrete example:
Server at
my-server/, with API implementation atsrc/ lib.rs my-server/.src/ http_entrypoints.rs API crate at
my-server/, calledapi/ src/ lib.rs my-server-api.Types crate at
my-server/, calledtypes/ src/ lib.rs my-server-types.Versions crate at
my-server/.types/ versions/ src/ lib.rs You’re adding a new version, 3, named
ADD_PARAM.
Determine the next API version
Examine the api_versions! macro in my-server/ to determine the next API version. Add the new version to the top of the list.
For example:
api_versions!([
(3, ADD_PARAM) // <-- Add this line.
(2, ADD_CONFIG_ENDPOINT),
(1, INITIAL),
])Add new or changed types to a new version module
If the new API version adds or changes types, you will put these types in a new module under my-server/.
Add this module to the versions crate’s lib.rs as:
#[path = "add_param/mod.rs"]
pub mod v3;Ensure there are no blank lines between pub mod vN declarations. This will cause rustfmt to sort the version numbers in a consistent order.
Within this version module, update mod.rs to look something like this, ensuring that <VERSION_NAME> is the named version identifier and not the numeric version. (Using the named version consistently ensures that in case of merge conflicts, the doc comment doesn’t fall out of date.)
// (License header here)
//! Version `<VERSION_NAME>` of <name of API>.
//!
//! (Add a brief summary of what was added or changed in this version. Don't
//! refer to future versions here, just past ones.)
pub mod config;
pub mod user;
// ...Mirror module organization from prior versions. For example, if a type in v1::inventory is changed in v3, add the new type in v3::inventory.
Arrange all types, including high-level request or response types, by function. Do not define params.rs, views.rs, or shared.rs.
vN modules. The vN modules should only contain and export types added or changed in that particular version.Add conversions to or from the immediately prior version
For changed types, per [determinations-one-prior-version], you may need to add:
For request-only types, define conversions from the immediately prior version of the type to the new one.
For response-only types, define a conversion from the new version of the type to the previous one.
For types used in both requests and responses, define conversions both ways.
All type conversions should be defined in the new vN module, not the prior version module. Use From or TryFrom if a conversion is self-contained, or an inherent method if ancillary data needs to be passed in. The Rust compiler will suggest missing implementations.
Within each submodule:
For type names that are not defined locally and are in prior versions, use fixed identifiers:
use crate::v1::user::UserParam;For type names that are defined locally and are in prior versions, import
crate::vNand usevN::paths to identifiers.use crate::v1;
pub struct UserData {
// ...
}
impl From<v1::user::UserData> for UserData {
// ...
}For type names from the same version, import them via
super, notcrate::vN.use super::config::ConfigData;
pub struct UserData {
config: ConfigData,
}
Define conversions using this template:
use crate::v1;
pub struct MyType {
// ...
}
// For request types:
impl From<v1::path::MyType> for MyType {
fn from(old: v1::path::MyType) -> Self {
// ...
}
}
// For response types:
impl From<MyType> for v1::path::MyType {
fn from(new: MyType) -> Self {
// ...
}
}
// For types used in both request and responses, implement both blocks
// above.In general, there is no need to implement conversions from other prior versions, since you can go through intermediate versions. In some cases it may be more efficient to do so anyway; use appropriate judgment.
Add or update re-exports in latest.rs
In each versions crate’s latest.rs, add or update re-exports for new and changed types, respectively. Put types for the current version in their own block. Within latest.rs, never use wildcard (*) exports.
For example:
pub mod inventory {
// Let's say this was an existing block of re-exports. In v3, inventory::Bar
// was changed and inventory::Baz was added. Then:
pub use crate::v1::inventory::Foo;
pub use crate::v1::inventory::Bar; // <-- Remove this line.
// Add this block to the end.
pub use crate::v3::inventory::Bar;
pub use crate::v3::inventory::Baz;
}Add new modules to the types crate if necessary
If the new version does not add any new modules, skip this step and proceed to the next step.
If the new version adds new modules, add a corresponding module to the types crate, and re-export the corresponding types from the versions crate’s latest module, using a wildcard identifier.
For example, if a new zones module is added, in my-server-types, add a zones.rs module with the following contents.
// License header here
pub use my_server_types_versions::latest::zones::*;Update the API trait
Update my-server/ with changes for the new version.
For changed and removed endpoints
Rename the existing endpoint to the version it was last changed in. This can be determined by looking at the first version listed in the endpoint’s
versionsattribute. (If theversionsattribute is missing, it is the initial version 1.)Add an
operation_idequal to the original endpoint name.Add the new version as the upper bound of the
versionsattribute.Update
latest::floating identifiers to their corresponding versioned identifiers. This might not be the same as the version determined in step 1.
For example, if an endpoint is defined as:
use my_server_types_versions::latest;
pub trait MyApi {
#[endpoint {
method = GET,
path = "/config/{user}",
versions = VERSION_ADD_CONFIG_ENDPOINT..
}]
async fn config_get(
rqctx: RequestContext<Self::Context>,
path: Path<latest::user::UserParam>,
) -> Result<HttpResponseOk<latest::config::Config>, HttpError>;
}Then, we can tell from the api_versions! list at the beginning of this guide that ADD_CONFIG_ENDPOINT corresponds to version 2. Also, let’s say that:
latest::user::UserParamis a re-export ofv1::user::UserParam.latest::config::Configis a re-export ofv2::config::Config.
Based on this, update this endpoint to:
use my_server_types_versions::{v1, v2};
pub trait MyApi {
#[endpoint {
operation_id = "config_get",
method = GET,
path = "/config/{user}",
versions = VERSION_ADD_CONFIG_ENDPOINT..VERSION_ADD_PARAM,
}]
async fn config_get_v2(
rqctx: RequestContext<Self::Context>,
path: Path<v1::user::UserParam>,
) -> Result<HttpResponseOk<v2::config::Config>, HttpError>;
}For changed and added endpoints
To the API trait, add the new version of the endpoint (for changed endpoints), or the new endpoint (for added endpoints).
Add the new endpoint without a version suffix.
Specify
versions = VERSION_<NEW_VERSION>...Use
latest::paths to types.For changed endpoints, add the new version above the just-renamed prior version, so that versions are in descending order.
For changed endpoints, the combined effect of the previous section and this one is that the method name is unchanged across versions.
For example, if you’re adding a changed config_get method with an additional query parameter:
use my_server_types_versions::latest;
pub trait MyApi {
#[endpoint {
method = GET,
path = "/config/{user}",
versions = VERSION_ADD_PARAM..,
}]
async fn config_get(
rqctx: RequestContext<Self::Context>,
path: Path<latest::user::UserParam>,
query: Query<latest::config::ConfigQueryParam>,
) -> Result<HttpResponseOk<latest::config::Config>, HttpError>;
// ... config_get_v2 immediately below here
}Never add types to {api-crate}/. All types should live in the versions crate. (This is a change from previous practice.)
For changed endpoints only
If possible (particularly if conversions only use From or TryFrom), make the prior version a provided method on the trait, with a default implementation that forwards to the corresponding latest versions. See [example-api-trait].
Regenerate OpenAPI documents
Run cargo xtask openapi generate. If all goes well, you’ll see:
all current versions of the API marked
Fresha new version
my-server-api/addedmy-server-api-3.0.0-{hash}.json
If one of the current versions errored out, you may have mistyped a versions bound or mixed up types. Double-check the output and diff to ensure that all previous types were preserved.
Update API implementations
In my-server/, update the API implementation with the corresponding changes.
For added endpoints
Add the endpoint’s implementation to the trait, importing types by name from the types module. For example, if a project_get endpoint is added:
use my_server_types::project::{Project, ProjectParam};
impl MyApi for MyApiImpl {
async fn project_get(
rqctx: RequestContext<Self::Context>,
path: Path<ProjectParam>,
) -> Result<HttpResponseOk<Project>, HttpError> {
// ... add the implementation here
}
}For changed endpoints
Update the endpoint’s implementation, noting that the method name remains unchanged, and continuing to use latest:: paths for types.
If the prior version is a provided method (the common case), no other changes are necessary. If the prior version is a required method, also add an implementation for that which does the necessary conversions.
For removed endpoints
The method name has changed, so perform the corresponding updates in the implementation. Remember also to update latest:: paths to versioned identifiers, mirroring the pattern used in the API trait.
Move non-conversion-related methods to newer types
Prior versions of types may have non-conversion-related methods or trait implementations defined for them. These methods typically need to be moved over to be implemented on the newer versions.
Generally, there’s no need for these methods on prior versions any more. In this case, move the corresponding methods to the newer versions of the types, next to where the types are defined (in our example, within the add_param module.)
Sometimes, the old types still need these methods, in which case copy them to the newer version of the types, next to where the types are defined.
Progenitor clients
As of this writing, every API has exactly one Rust client package and it’s always generated from the latest version of the API. Per [rfd532], this is sufficient for APIs that are server-side-only versioned.
Within Progenitor clients for server-side versioned APIs, replace statements must always continue to use floating identifiers from latest::.
For APIs that will be client-side versioned, you may need to create additional Rust packages that use Progenitor to generate clients based on older OpenAPI documents. This has not been done before but is believed to be straightforward.
Rationale
This section describes the reasoning for the determinations listed above. Most of this section follows from this RFD’s principles, though there are a number of decisions that came about through prototyping and experimentation.
Rationale for versions crate
Alternative: same crate for types and versions. This has upsides, such as simplicity, and there being one floating identifier rather than two (latest.rs likely becomes unnecessary.) But we’d like to maintain the property that conversion code should be self-contained. With the chosen approach, code that needs to do conversions must explicitly depend on the versions crate. Other code will not have access to prior versions of types, including in rust-analyzer suggestions.
Alternative: high-level request and response types remain in the API crate. In this scenario, if a high-level type changed, the developer would have to copy over the existing definition of the type.
Doing this for high-level types, with 1-3 fields and no attached methods, is easier than copying around core types like
OmicronSledConfigwhich are large and have many methods.This would have the advantage of requiring less code to be compiled before the types crate is reached. The types crate tends to be a compile-time bottleneck because all business logic depends on it.
But deciding which types count as high-level (and are therefore defined in the API crate) and which don’t (and are therefore defined in the versions crate) requires judgment, which is worth saving for thornier problems. There also isn’t a great place to put conversion code—the API trait’s lib.rs is a pretty awkward place to put such code. So on balance, it is better to have a uniform set of rules governing the locations of types.
Alternative: old versions of types are defined in the API crate. This was the status quo prior to this RFD. But it has two issues:
The canonical definition of a type can no longer live in the types crate. Changing a type requires copying the existing definition and adding it to a module for the previous version. This can be quite burdensome, as outlined in [motivation].
Some business logic also needs access to version conversion code, and having that business logic depend on the API crate is strange—particularly when that business logic is far away from Dropshot or HTTP server-related code. (In general, this is why we maintain crates for common vocabularies of types.)
Alternative: versions crate depends on types crate. One possibility is that the versions crate sits atop the types crate:
While this would solve issue 2 above, it wouldn’t solve issue 1, because current versions of types have to be defined in the bottom-most crate in the graph, which in this case would be the types crate. The only way to avoid having to copy types around when they’re superseded is to have the versions crate be the bottom-most crate with the canonical definition of the types, and the types crate re-export them.
Alternative: no shared crates. An interesting option is to not have omicron-common-versions at all. Instead, have unpublished types in omicron-common, and then published types in each crate that mirrors those unpublished types. This would be a future direction to explore (TODO link to open question about this), but this RFD is already quite disruptive, and we’d like to limit its scope in the interest of urgency.
Naming: We chose "versions" rather than "migrations" because "migrations" implies a one-way conversion. Developers have to provide conversion methods both ways, depending on if the type is a request or a response type.
Rationale for top-level vN modules
Alternative: nested by domain first. Rather than sled_agent_types_versions::v1::inventory::Inventory, one could do sled_agent_types_versions::inventory::v1::Inventory. That is somewhat more inconvenient with named subdirectories in the presence of merge conflicts.
With the chosen approach, resolving a merge conflict only involves fixing the merge conflict in
lib.rs.With this alternative approach, resolving a merge conflict would involve editing files corresponding to each domain that conflicted. Files that didn’t conflict might also need edits as well, which can be easy to get wrong.
Alternative: all type versions in one module. Rather than v1::Inventory and v2::Inventory, one could put InventoryV1 and InventoryV2 in inventory.rs. This would have merge conflict issues similar to those described in the alternative above.
Alternative: separate crates by version. For example, sled-agent-types-v1, sled-agent-types-v2, and so on. The appeal of this approach is that [determinations-only-prior-versions] can be encoded in the crate graph. But adding a new version would be quite heavyweight. Also, we’d want to use named subdirectories to avoid merge conflicts, which adds further complexity. The current module structure aims to be a middle ground between putting all versions in one file and putting them in separate crates.
Alternative: not requiring submodules under vN. Some simpler APIs could have all their types live in one file. But requiring a submodule structure is good for uniformity across versions crates, and the burden is relatively small.
Alternative: no shared crates. Having slightly different versioning schemes for API-specific and shared versions crates is definitely a wart. But as mentioned in [rationale-versions-crate] above, we’d like to avoid scope creep in this RFD.
What about types shared across APIs but stored in sled-agent-types? This situation is uncommon, and we’ll need to probably make a case-by-case determination about it. One solution would be to duplicate the type and put bidirectional conversions somewhere. Another is to have a shared_vN set of modules.
Rationale for named subdirectories
Alternative: not being prescriptive about directories versus files. In the RFD we propose always using a two-level version module scheme, with directories for version modules. For more complex APIs, directories always make sense. Smaller APIs might not need to use directories today, but if they grow over time, it would make sense to turn those files into directories in the future. Changing old version modules would be quite inconvenient at that time, and this determination is made foreseeing that possibility.
Alternative: using vN/ paths. Using #[path = …] in Rust is quite rare–using vN/ would be the usual way to manage this. But that runs into merge conflict issues, especially if two developers simultaneously make changes to unrelated parts of an API. Both would modify files inside the vN directory, and disentangling the modifications becomes quite painful. Using named subdirectories makes merge conflict resolution much simpler: typically, only lib.rs needs to be updated.
Using named subdirectories is very similar to the way we store database migrations in Omicron, with named subdirectories by version and a top-level map. That storage model was also driven by merge conflict concerns.
Alternative: have named and vN top-level modules to avoid a #[path] directive. With this alternative, lib.rs might look like:
mod initial;
pub mod v1 {
pub use crate::initial::*;
}This alternative is strictly worse than a #[path] directive:
There are now two versioned identifiers accessible within the crate, and with rust-analyzer imports, which one is chosen is non-deterministic.
Control-clicking the module name in rust-analyzer would lead to the
v1inlib.rs, and not directly toinitial/.mod.rs
Alternative: use a macro to generate #[path]. For example:
version_modules! {
v1 => initial,
v2 => add_param,
// ...
}This is a bit harder to get wrong, and there’s already precedent for using macros as api_versions!. But macros, being custom syntax, have a baseline cost: for example, rust-analyzer and rustfmt work less well. At this point, the benefits of macros do not seem to outweigh the costs.
Alternative: store version modules as v1_initial/. The chosen approach has the downside that you can’t tell which version corresponds to a particular directory without looking at lib.rs. We considered module names like v1_initial, but there’s the risk that (especially when rebasing across versions) the version numbers in directory paths fall out of sync with the version numbers in lib.rs. If and when we have a linter which can detect this error condition, this may be worth revisiting. (Because the impact of version module paths is local to the versions crate, iterating on version module paths is relatively simple.)
Overall, a macro + a linter + paths like v1_initial is a valid point in the design space, and one worth thinking about more deeply in the future.
Rationale for types being in the earliest version
Alternative: store types in the last version before they change. This was the status quo prior to this RFD. For example, with the Sled Agent API, OmicronSledConfig changed in version 4, and then in version 10. The previous version of OmicronSledConfig valid from versions 4 through 9, inclusive, was stored in a v9 module. That makes sense in a world where the latest versions of types aren’t stored in version modules. But this RFD explicitly moves away from that scheme, and in this RFD’s proposed scheme it makes far more sense to store types in the first version they were introduced in.
Alternative: re-export unchanged types in each version. For example, v2 re-exports every type present in v1. With this alternative you would get a consistent view of all types present in a particular version (thus making latest.rs mostly redundant). But this is impractical for large numbers of types, also causes an explosion in versioned identifiers.
Rationale for referring to prior versions
Alternative: conversions live in the older version, not the newer one. So when converting to v4::Inventory to v9::Inventory, the code to convert from v4 to v9 would live in the v4 module, not the v9 module.
The scheme chosen in this RFD follows the principle that version modules should generally be semantically immutable, or in other words that developers don’t have to go back and edit old modules to do conversions. There don’t appear to be any benefits to putting conversions in the old module.
Alternative: conversions live in a separate module: either a vN::conversions module or a top-level conversions module. Both of those options seem more confusing than the scheme chosen in this RFD.
Alternative: conversions from more than one prior version. For example, let’s say Inventory was introduced in version 1, and changed in version 4 and version 9.
If
Inventorythen changes in version 10, the developer would have to write conversion code from versions 1, 4, and 9.Then, if the type changes again in version 12, the developer would have to write code from versions 1, 4, 9, and 10.
In other words, as the number of versions of a type grows, the number of conversion methods would grow proportional to that. This seems to be an unnecessarily burdensome approach. In general, hopping through intermediate versions is more straightforward, which is why this RFD recommends that.
Alternative: custom trait for conversions. In the RFD, we propose using, in order:
FromTryFromAn inherent method, if ancillary data is required
One alternative is to have a custom trait that abstracts over these options (playground):
pub trait ConvertFrom<T>: Sized {
type Data;
type Error;
fn convert_from(from: T, data: Self::Data) -> Result<Self, Self::Error>;
}
impl<T, U> ConvertFrom<T> for U
where
T: TryInto<U>,
{
type Data = ();
type Error = <T as TryInto<U>>::Error;
fn convert_from(from: T, (): ()) -> Result<Self, Self::Error> {
from.try_into()
}
}At the moment, the benefit of this abstraction is unclear, though if a use case comes up we may want to explore this option in the future.
What if a type’s meaning changes completely in a version? Ideally, the type would be named something different. But if it must be named the same, one would just not define conversions from or to that older version.
Rationale for latest.rs re-exports
Alternative: no floating identifiers. This RFD proposes a scheme where each published type is available through both a versioned identifier and a floating one. One option is to not have floating identifiers at all, and to always use versioned identifiers.
In the alternative world, whenever a type is changed, business logic would need to be updated to refer to the new identifiers. This goes against our principles that:
regular business logic need not be concerned about API versioning
the burden of a change should be proportional to the number of types changed
Also, Progenitor replace statements in client crates would have to be updated each time there’s a new version. If a developer forgets to do that, there would be a potential mismatch between type schemas. (As of December 2025, we do not have a check for replace types' schemas being wire-compatible.)
Alternative: no floating identifiers in versions crate. One option is to retain floating identifiers, but only in the types crate. This is certainly simpler (only one floating identifier, not two), but it means that:
API and client crates would always have to depend on the types crate.
It is also easier to read off all floating identifiers by looking at the one file.
Alternative: automatic generation of latest types by scanning directories. The latest.rs file could be generated by scanning the source code of version modules. But having this manual step is both more legible, and ensures that the right types are being exported in the types crate. Worth considering having a lint check for this, though.
Alternative: don’t separate groups by blank lines. Having blank lines makes it easier to tell which version an identifier comes from. Since we also determine that re-exports are grouped together by version in ascending order, colliding changes always result in merge conflicts in this file.
Alternative: wildcard re-exports. Wildcard re-exports in the latest module work fine if all types are from a single version, but quickly become illegible when types within a module are spread across multiple versions. Having a uniform rule where all types are always listed out reduces the burden of adding new versions.
Rationale for impls module
Alternative: functional code next to type definitions. This would mean that each time a new version of a type is added, functional code would have to be moved or copied along with it, which is quite burdensome. This is also particularly problematic with merge conflicts, where both sides of the conflict might alter functional code in their own ways. Having non-version-related code live in an impls module provides the right affordances around merge conflicts.
Alternative: functional code in types crate. There are a couple of options here:
methods on an extension trait
free functions
This is appealing (and an earlier draft of the RFD proposed this approach), but in practice it was found that both of these options introduce a fair bit of extra boilerplate that didn’t appear to carry its weight.
How do we decide whether code is functional or version-related? Some code like custom displayers are clearly functional, while other code like JsonSchema is clearly version-related. There are some edge cases, though. The ultimate driver of this is minimizing ongoing porting costs. If some method that we thought was functional was actually version-related, then it is not too difficult to copy that method into the corresponding version module.
Alternative: bias towards version module. This RFD’s guidance is that edge cases should be treated as functional and put in the impls module. Moving code from impls into version modules is slightly simpler than the other way round, particularly in the presence of merge conflicts.
Alternative: impls module uses versioned identifiers. The point of the impls module is to decouple functional code from versioned identifiers, which is why we always use floating identifiers within it.
Alternative: impls module is public. We would like to push developers towards using floating identifiers from the latest module. Extra types (such as error types) defined in impls should be re-exported from latest.rs.
Proc-macro-generated functional code. Some non-version-related functional code is generated by procedural macros—an example is daft::Diffable. Rust requires that these kinds of proc-macro invocations are made as annotations on the type definition. These proc-macro invocations would have to be copied into newer versions of a type, and practically speaking, unused proc-macro invocations in older versions are unlikely to be cleaned up when a new version is introduced. This is unfortunate, but the best tradeoff possible in this situation. As support for old versions is dropped, these proc-macro invocations will be cleaned up.
Rationale for types crates doing wildcard re-exports
Alternative: no re-exports in types crate. With this alternative, business logic would always have to depend on the versions crate, which goes against our principle that conversion code should be self-contained.
Alternative: deprecate types crate entirely. As noted above, we would like it so that only boundary code depends on the versions crate. Regular business logic gets to depend on the latest re-exports in the types crate, and to the extent that it doesn’t have a direct dependency on the versions crate, cannot accidentally depend on old versions.
Alternative: not using wildcards in types crate. The versions crate already has the canonical list of the latest types by version (and we prohibit wildcards in latest.rs). Also, requiring types to be explicitly listed out in the types crate increases boilerplate without a clear benefit.
Rationale for API trait identifiers
Alternative: use re-exports from the types crate. By definition, the API trait only consists of published types. So it should normally be possible to not depend on the types crate. Using floating identifiers from the versions crate rather than the types crate helps achieve that goal.
Alternative: use versioned identifiers for the latest versions of each API. Always using versioned identifiers has some benefits: if an API is changed, the previous version’s type references don’t need to be updated to account for that. But there would then be a mismatch between how the API trait refers to a type and how Progenitor replace statements refer to it. If a developer makes a mistake here, there would be a potential mismatch between type schemas. (As noted above, as of December 2025 we do not have a check for replace types' schemas being wire-compatible.)
Alternative: some other non-descending order. Descending order was chosen for uniformity, and based on the logical principle that the most important definitions come first.
Alternative: suffix latest version endpoints with a version number. The downside of this is that method names in Progenitor clients would no longer match methods on the API trait.
Alternative: use prefixed version numbers rather than suffixes. So, v3_my_endpoint rather than the my_endpoint_v3 chosen in this RFD. We did a quick survey and found that suffixed version numbers were more common. Also, several developers on the update team expressed a preference for suffixes. This is a minor point, though, and having a uniform rule is more important than what the rule is.
Alternative: version numbers are suffixed with the last version they were used in. So, if an endpoint my_endpoint is introduced in version 4 and then in version 10, rename it to my_endpoint_v9 rather than my_endpoint_v4. We choose the scheme in the RFD for consistency with [determinations-earliest-version]. But the rationale for this choice is less strong than the rationale for types being arranged that way, since no code is being moved around.
Alternative: don’t use provided methods. In the RFD, we propose converting types to the newest versions within the API trait, using a provided method with a default implementation if possible. One alternative is to always use required methods and do this conversion within implementations. But that would mean that for API traits with multiple implementations, conversions would have to be repeated across each implementation. Nevertheless, we leave open the option of using required methods in cases where that’s necessary.
Alternative: handle conversions in middleware. Dropshot explicitly does not support middleware, preferring to use explicit types instead.
Rationale for API implementations matching trait identifiers
Alternative: use versioned identifiers for latest versions. While the Rust compiler would catch mismatched types between the API trait and the implementation, this seems strictly worse than using floating identifiers.
Alternative: use floating identifiers from the versions crate. We recommend using floating identifiers from the types crate to go along with rust-analyzer’s auto-import heuristic that prefers shorter paths. Paths to types in the types crate are always shorter than the paths to the same type in the versions crate (whether versioned or floating), so rust-analyzer’s auto-imports prefer that.
Rationale for Progenitor replace statements
Alternative: use versioned identifiers in replace statements. As outlined above, using floating identifers avoids a potentially dangerous mismatch between source types and their corresponding replace equivalents.
Rationale for one-time migration
Alternative: gradual migration. Biting the bullet and doing the migration once (particularly because LLMs have been helpful) reduces ongoing work compared to a gradual migration over time, where some types use previous styles of organization and others use this RFD’s proposed style.
Alternative: only apply to new APIs. The motivation for this RFD was that altering existing APIs could be quite burdensome; only applying this scheme to new APIs would defeat the purpose.
External References
[RFD 418] RFD 418 Towards automated system update
[RFD 421] RFD 421 Using OpenAPI as a locus of update compatibility
[RFD 479] RFD 479 Dropshot API traits
[RFD 567] RFD 567 Client-side-versioned APIs
[RFD 576] RFD 576 Using LLMs at Oxide
[RFD 594] RFD 594 Release Support Policy