Evolution rules¶
Two different things can break when a WireWeaver crate changes, and they are tracked separately:
- Wire compatibility - whether bytes written by one side can still be read by the other. Firmware lives in the
field for years, hosts are updated on their own schedule, and ShrinkWrap blobs may be stored on disk. A wire break
means deployed devices stop talking to new hosts (or the other way around), which no
cargo updatecan fix. - Rust (SemVer) compatibility - whether code still compiles against the new crate version. A break here is annoying, but it is found by the compiler and fixed by the developer, before anything ships.
A change can break one without the other: renaming a BufWriter method breaks Rust code but not a single byte on the
wire; changing a field's u8 to u16 in an API type compiles fine and silently breaks the wire. The rules below say
what you can change in your API crate without breaking either, and which version to bump when you do.
For changing WireWeaver itself or a ww_stdlib crate, see crate layers and version bumps.
In short¶
A device runs blinky_api 0.1.0. The next version adds a method and a field, and keeps everything else:
#[ww_api_root]
pub trait BlinkyApi {
fn led_on();
fn led_off();
#[since = "0.1.1"]
fn led_toggle();
}
#[derive_shrink_wrap]
struct Status {
on: bool,
#[since = "0.1.1"]
#[default = None]
brightness: Option<u8>, // old data reads as None
}
Both are wire-compatible additions, so the crate goes from 0.1.0 to 0.1.1: an old device and a new host still
connect, the host gets a clean error when it calls led_toggle on the old device, and reads None for the
brightness it doesn't send. The rest of this page lists which changes are compatible like these. When in doubt,
run the evolution checker, it compares your crate with its last release.
Your API's version¶
Name and version of the user API crate are sent during link setup, and both device and host refuse to connect if
they are not compatible (ww_version::Version::is_protocol_compatible):
- Before 1.0: major and minor must match, so
0.2.0and0.2.7connect,0.2.0and0.3.0don't. - After 1.0: major must match.
So the compatible position (patch before 1.0, minor after) is for wire-compatible additions: new methods,
properties, streams, new fields with defaults, etc. Mark them with #[since = "0.2.3"], so that a newer host can
report a clean error when calling a resource that an older device doesn't have (see examples/blinky_api_evolved).
Everything else bumps the breaking position.
The API crate's dependencies are part of this contract: upgrading a ww_stdlib crate or shrink_wrap to a
wire-incompatible version, or the API model, is a breaking change of the user API too.
Doc comments¶
Doc comments on traits, resources and types are not part of the wire format, but they are shipped in introspection
data and in crate snapshots, so a GUI or CLI can show them. A released version's snapshot never
changes, so a doc-only change is still a new version: bump the compatible position (patch before 1.0, minor after),
same as for a wire-compatible addition. Devices with the old version keep connecting, and tools can tell that
improved docs are available. ww api save refuses to overwrite a snapshot with changed docs, and the
evolution checker requires the compatible bump.
Data types¶
WireWeaver considers two root types for evolution and evolution rule checks - struct and enum (plain and with data
variants).
Size of a type is marked as one of: Unsized, FinalStructure, SelfDescribing or Sized.
Unsized types¶
By default, user defined struct or enum is Unsized. Both can contain variable-size types - vectors,
strings or other structs and enums. Unsized types support all the evolution options. It is recommended to stick with
unsized types, unless extreme space-saving is required.
When serializing, size of such objects is calculated and written to the resulting byte array. Which is the only overhead, giving all the nice backwards and forwards compatibility benefits.
- New fields with
#[default = ..]can be added to the end of structs and enum struct variants. Old data reads as the default (see derive),Tcan be anything:Option<T>-#[default = None],Vec<T>,String-#[default = Vec::new()],#[default = String::new()],- a bit-sized field (
bool, nibble,Option/Resultflag) can land in padding bits of the old data's last byte, which read as 0: give it its zero value as the default.
- New enum variants can be added at the end. Readers of older versions fail on them unless the enum has a
#[future_version]variant; with one, they get its discriminant. EveryUnsizedenum has one unless it saysno_future_version(SW-47). In anUnsizedenum new variants may carry data, in asizedorself_describingone only new unit variants can be read past by older readers. Adding or removingno_future_versionchanges the Rust type, not the wire: a Rust API break only. The checker knows the catch-all (EVO-10): a variant appended to an enum with one is a compatible change, one inserted before others or a reordering moves a variant to another discriminant and stays breaking, as does any new variant of an enum without the catch-all. The API model records the catch-all since EVO-10, so a snapshot saved before it counts as one without: save it again (ww api save) for appended variants to pass. - New fixed size fields can be added in between old ones, into previously unused padding bits, without moving any other field (see the checker for exact conditions).
- TODO: clarify:
T-> struct containingT - Struct fields and enum variants can be renamed (but their position must NOT change).
- A tuple struct (
struct Pair(u8, u16);) follows the same rules by position: new fields with#[default = ..]only at the end. Naming the fields of a tuple struct, or the other way round, keeps every byte, but the API model then describes a different type (named instead of unnamed fields), which the checker reports as breaking. A unit struct (struct Marker;) can get fields with defaults later, named or not.
A TailSize<N> field (see wire format) is part
of the layout like a Sized field: adding, removing or moving it, or changing N, breaks compatibility; fields
after it evolve as usual, and old readers skip what newer writers append.
A Series<T> field is like a Vec<T> field: it can be appended with
#[default = Default::default()] (old data reads as an empty series), but swapping it for a Vec<T>, or changing
T other than as T's own rules allow, changes the bits and breaks compatibility.
Maximum lengths: MaxLen<T, N>¶
A MaxLen<T, N> has the same bytes as T, so a bound never
changes the wire, only which values the reader accepts (a longer one is an error, never truncated). Whether a change
is compatible depends on what the old version writes:
- Raising a bound or removing it (
MaxLen<T, 64>->MaxLen<T, 256>orT): compatible. Everything the old version writes is still read. The checker lists it asmax length raised, because values the new version writes beyond the old bound are rejected by the old version (with an error): a new client sending a long piece to an old device gets an error reply, the same as any argument the device refuses. - Lowering a bound or adding one (
T->MaxLen<T, 64>,MaxLen<T, 256>->MaxLen<T, 64>): breaking. Values the old version is allowed to write no longer fit, and nothing on the wire tells the old version so. - Changing
Tinside the bound follows the usual rules forT.
At connect time the client does not refuse a device whose bounds differ from its own: a longer value is rejected when it is sent, by whichever side has the smaller bound.
Overflow policies of Fixed¶
The bounds marker B of an exact_units::Fixed that is Unbounded, Wrapping or Saturating (STD-63) has no
bytes of its own: changing one into another keeps the bytes and the value they mean, and changes what arithmetic on
the value does (a Bam16 wraps at a turn, a Q15 stops at its ends). Every reader still reads every value. Whether
the checker reports such a change, and how, is open: EVO-17. A change of the unit or the scale is a change of
meaning, not of policy.
Compressed sequences: Series<T>¶
A Series has bytes of its own, so Vec<T> -> Series<T> or Series<u32> -> Series<u64> is breaking (ww api
check reports it), unlike the borrowed / owned form (Series<'i, T> / SeriesOwned<T>), which is the same type.
The codecs of its columns are not part of the type: each writer picks its own (CodecPolicy), every reader reads
them all. A struct or enum T evolves inside a Series as it does on its own (see Series).
Sparse structs¶
A sparse struct (DER-13) has a slot per field, all of
them Option<T>:
- New fields can be appended after the last slot, without
#[default]: older data reads them asNone, older readers ignore them. - A field can't be inserted before the last slot, also not into a gap left by
#[slot = N]: a reader that doesn't know the slot can't step over a present value in it and fails withError::OptionAnyVecUnknownSlot. Removing a field breaks too: data from an older writer can still have it present. - A field's slot and its
Tcan't change (Tevolves by its own rules); fields can be renamed. - Turning an ordinary struct into a
sparseone or back changes every byte: breaking.
Generic types¶
A generic struct or enum (Timed<T>, Window<const N: usize>, API-43) evolves like any other: its fields by the
rules for its size assumption, its arguments by their own rules (Timed<Ampere> changes when Ampere does). Its
parameters are compared by position and kind:
- Renaming a parameter (
Timed<T>->Timed<V>) changes nothing. - Adding, removing or reordering parameters, turning a type parameter into a const one, or changing the type of a
const parameter (
usize->u8) is breaking, also when a default keeps old code compiling: the uses on the wire carry one argument per parameter. - Making a fixed length a parameter (
[u16; 4]->[u16; N]) or back is breaking for the definition; uses that end up with the same length (Window<4>) have the same bytes. - A use with other arguments (
Window<4>->Window<8>,Pair<u8, u16>->Pair<u8, u32>) is another type, as[u16; 4]->[u16; 8]is.
Transparent wrappers, units and scales¶
A transparent struct (DER-18) is exactly its one field on the wire,
ww_si::Fixed<U, P, T, B> (STD-55) is one: a T with its unit U, prefix or scale P and bound B in the type.
The checker compares it by what it means, not only by its bytes:
T-> a wrapper ofT, or back (shared_us: u64->shared: Fixed<Second, Micro, u64>): compatible, the bytes are the same. The checker lists it asu64 -> Fixed<Second, Micro, u64, Unbounded>, same bytes. Renaming the field with it (dropping the_us) is compatible as well (fields are matched by position), but Python andwwscripts that name the field change with it.- Another unit, prefix or scale (
Fixed<Second, Micro, u64>->Fixed<Second, Milli, u64>): breaking, although the bytes are the same: a reader of the old version would take a value in ms for µs. Units and scales are compared by value, so another name for the same one is compatible (Milli->Pow10<-3>,Fixed<Volt, Milli, ..>->Fixed<MilliVolt, One, ..>), and lbf·s -> N·s is breaking with the factor in the finding (EVO-17, see units compared by meaning). Origins of points are compared by where their zero lies. The same for one wrapper replaced by another wrapper of the same bytes (Fixed<..>-> aCount<u64>of your own). - Another
T(u32->u64, inside the wrapper or across it): breaking, as for plain numbers. Quantity<U>->Fixed<U, ..>: breaking, aQuantitysends its prefix and number type with every value, aFixedonly the number. It is the intended move for values whose prefix and type never change, so the checker says so in the finding: plan it for a major version.
Fixed<Second, Micro, u64> and Fixed<Second, Micro, u64, Unbounded> are the same type (the model always records
B), and so is an alias of either (Micros<u64>). Fixed in your API is a ww_si type the API model skips; the
checker reads its definition from the ww_si snapshot built into it, so a field changed from u64 to Fixed is
checked the same way as a wrapper of your own.
This holds for every generic type, not only Fixed: a type parameter that no field uses (one only in PhantomData,
which the model doesn't have) is a marker that gives the bytes a meaning. A marker with a definition in the model (a
unit, scale or origin, INT-13) is compared by that definition; any other marker by name, with its own arguments
(Count<Apples> -> Count<Pears>). A marker's definition is not compared field by field, it has no fields.
Bounds¶
A bound (B of Fixed: Unbounded, Range<MIN, MAX>, RangeU64<MIN, MAX>, RangeDec<MIN, MAX, EXP>) doesn't
change the bytes, but a reader refuses a value outside its bound (on decode, before a handler runs). So a bound is part
of the contract, and any change of it breaks one side, depending on who writes the item:
- Narrowed (
Range<0, 300>->Range<0, 100>,Unboundedor plainT->Range<..>): the new version refuses values the old one writes. For a method argument or a writable property that is an old host's call refused by a new device; for a property or stream the device writes, an old device's value refused by a new host. - Widened (
Range<0, 100>->Range<0, 300>,Range<..>->Unboundedor plainT): the old version refuses values the new one writes: a new host's argument refused by an old device, a new device's value refused by an old host.
Both are breaking, unlike a max length raised, and the checker says which side writes
and which refuses: Api::uptime: B of Fixed: bound 0..=600 on the new version, 0..=300 on the old version: values the
new version writes outside 0..=300 are refused by the old version. Bounds are compared by value: RangeDec<-5, 25,
-1> and RangeDec<-50, 250, -2> are both -0.5..=2.5, the same bound. They are recognised by name and arguments
wherever they come from; a bound marker of your own with other names is compared as any marker, by name.
In the place of a bound, Saturating is the same values as Unbounded (compatible), while Wrapping on one side
only is breaking: a wrapping number is a position modulo its storage, the reader would take a wrap for a jump (see
units compared by meaning).
FinalStructure, SelfDescribing and Sized types¶
Sizedcan becomeSelfDescribingand back (sized<->self_describing): neither gets a size slot from its parent, the bytes are the same, and the checker reports it as compatible. Asizedenum must have variants of one size (see derive).- New fixed size fields can be added in between old ones, into previously unused padding bits, without moving any other field (see the checker for exact conditions).
- Struct fields and enum variants can be renamed (but their position must NOT change).
API¶
Data types used in API are a part of SemVer guarantee and are subject to the rules above. Meaning that it's not allowed to break compatibility on any of the data types used directly or indirectly without also bumping the breaking position of the API version as well (minor before 1.0, major after, see your API's version).
- Adding argument
Tto struct ofTin return positionTtoVec<T>in return position?
Streams (API-30, API-31, stream reliability): the delivery kind of a stream is part
of the API. Turning a plain stream! into a #[counted] or #[reliable] one (or back, or one into the other), or
giving a stream arguments or a result (#[stream(T)] fn name(args) -> Result<A, E>), changes its events and how it is
opened, so it is a break of the API crate: bump its breaking version position, as for a plain sink becoming a
#[credit] one. A plain #[stream(T)] fn name() without arguments and result is the same as stream!(name: T), the
same bytes in introspection.
Batching a stream (API-41: batched / batched(untimed) in #[stream(..)], #[batched] next to stream!), or
un-batching it, or switching between timed and untimed, changes its item type on the wire (one Series<Timed<T>> or
Series<T> per event instead of one T): a break of the API crate too. The batch size is the device's (it is not in
the API), so changing it is not a break.
Making a property +batched (API-39) or not changes its observe slot (a Batched<T> instead of a T): a break of
the API crate. The times of the samples travel in the PropertyUpdate event, not in a slot, so adding resources to a
level with +batched properties follows the usual rules.
Optional items¶
#[optional] / #[optional(group)] on a method, property, stream or ww_impl! (API-16, API-54) is not on the wire:
an item keeps its id whether a device has it or not, and a device leaving it out answers Unimplemented, which a
client already has to handle for any call. But servers name optional items and groups in omit = [..] / omit_if,
so ww api check treats the marker by what it does to their Rust code:
- Making an item or a
ww_impl!optional: compatible. Clients of the new version learn from a device's model that it may leave it out; clients of the old version getUnimplementedfrom a device that does. - Putting an
#[optional]item into a group (#[optional]to#[optional(group)]): compatible,omitby the item's name still works. - Making an optional item required again, moving it to another group or out of its group: a break of the API crate. A server leaving it out no longer compiles (no handler), or silently keeps an item it meant to leave out.
Metadata attributes¶
#[claim(..)], #[shares(..)] and #[label(..)] (SRV-34, Metadata) are not on the wire and a
server doesn't use them: adding, changing or removing one is compatible, ww api check lists it. Only the
introspection data (and so the API hash) changes. Removing a #[claim] does change what the hub lets a program do: a
program that claims that resource is refused from then on, so say so in the changelog.
API model¶
API model (like ww_client_server) is part of the compatibility equation, it is not allowed to update the model
version without breaking compatibility.
E.g., if user_device_api v0.1.0 depends on ww_client_server v0.4.0 and a new major version of ww_client_server comes out (v0.5.0), then user_device_api must be bumped to v0.2.0 to use the newer API model. This should only happen to add new features though, and if previous version is doing all that it is supposed to, there might not be a need to upgrade.
The ww hub and version skew¶
The ww hub (SRV-30) sits between programs built with different WireWeaver releases and the devices, so it adds rules of its own:
- The preamble is frozen. Its bytes never change; new fields are new trailer entries (a new tag), which a reader that doesn't know them skips. That is what lets any client and any hub exchange their versions and a clear refusal.
- What the hub serves. A client says its
ww_linkandww_client_serverversions in the preamble; the hub serves every version its build is compatible with by Cargo's SemVer rule (the same major, 0.x: the same minor). A wire-compatible change of either is invisible to the hub. A breaking one means: the hub keeps the previous major's code for one more major and lists it in its window, the way a device keeps an old protocol, so programs built before the break keep working through a newer hub until they are rebuilt. A client outside the window is refused (status 4) with both versions and the fix, never served half-way. - The user API is end to end. The hub reads the envelope only, never a user API: a user API change is between the client and the device as without a hub, and the hub doesn't need rebuilding for it.
ww_hub_apievolves like any API crate: additions are wire-compatible (an older hub answersUnimplemented),Hub::infoandHub::handoverare frozen.- Newer replaces older. A program that can host a newer hub than the running one has it hand over; only a strictly
newer
ww_hubversion replaces an older one, so a PC converges on its newest hub.
Settings stored on a device¶
A settings store (ww_settings, STD-32) keeps a record per group in the device's flash, and
the record outlives the firmware that wrote it: an update reads the old firmware's record, a downgrade the new one's.
Each field has a slot, each stored field is a byte blob of its own, so every firmware can step over a slot it
doesn't know. That makes the rules looser than for a sparse struct:
- Adding a field: any slot that was never used, also one in a gap between older slots. Older records read it as its default. An older firmware keeps it unchanged when it writes its record.
- Removing a field: put its slot into
#[settings(retired(..))]; the derive then refuses to give it to another field. A record that still holds it is read without it, and the slot is dropped on the next write, so a downgrade to a firmware that still has the field finds it at its default. - Changing a field's type: only the way the type itself may evolve (a struct gets fields with
#[default]appended, an enum gets variants). An older firmware reads what it knows of the newer value (a struct's first fields, an enum variant it doesn't know asUnknown(n)), and as long as it doesn't change the field, it writes the newer bytes back unchanged; if it changes the field, the parts it doesn't know are gone. Bytes it can't decode at all (an enum withno_future_version) are kept while the field stays at its default. Any other type change, or a change of what the value means (units, scale): a new slot, and the old one retired. - Changing a default: a stored record holds only the fields that differ from their default, so every device that never set the field takes the new default, and so does one that set it to exactly the old default. When that is wrong, the field needs a new slot.
- Renaming a field is fine; the slot is what is stored. Moving a field to another group (another
Key) is a new field there and a retired one here; copying the value over is the firmware's job on load. - The record format (
ww_settings::record::FORMAT) and the store layout (the page and item headers inww_settings::store) are defined byww_settingsitself: a change to either is a breaking change ofww_settings, as forww_framerin layer 3. A firmware that finds a record in a format it doesn't know leaves it alone (is_read_only) instead of overwriting it.