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.
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