# pack-io v0.5.0 — Schema evolution + feature freeze
**The third "make us better than the rest" pillar lands, and the feature surface closes.** v0.5.0 ships append-only schema evolution via `#[pack_io(version = N)]` / `since` / `deprecated` — the property that lets two services at different revisions of the same type keep talking — plus `peek_version` for runtime dispatch. From this release the public API and feature surface are **frozen**; v0.6 is an optimisation pass against the surface that exists today, not a new feature pass. The wire format gets one additive extension (versioned structs); every v0.4 payload remains valid.
## What's new in 0.5.0
### Schema-evolution attributes
Tag a type with `#[pack_io(version = N)]` and individual fields with `#[pack_io(since = N)]` / `#[pack_io(deprecated = N)]`, and the derive macro generates a length-framed encoding that lets old and new revisions of the type interoperate transparently:
```rust
use pack_io::{Serialize, Deserialize, encode, decode, peek_version};
// v1 of the type: id + text.
#[derive(Serialize, Deserialize)]
#[pack_io(version = 1)]
struct MessageV1 {
id: u64,
text: String,
}
// v2 appends `timestamp`. Old encoders never wrote it; new decoders default it.
#[derive(Serialize, Deserialize)]
#[pack_io(version = 2)]
struct MessageV2 {
id: u64,
text: String,
#[pack_io(since = 2)]
timestamp: Option<u64>,
}
// Old wire, new code: timestamp defaults to None.
let v1_bytes = encode(&MessageV1 { id: 7, text: "hello".into() }).unwrap();
let upgraded: MessageV2 = decode(&v1_bytes).unwrap();
assert_eq!(upgraded.timestamp, None);
// New wire, old code: trailing timestamp bytes are skipped inside the body frame.
let v2_bytes = encode(&MessageV2 {
id: 7,
text: "hello".into(),
timestamp: Some(42),
}).unwrap();
let downgraded: MessageV1 = decode(&v2_bytes).unwrap();
assert_eq!(downgraded.id, 7);
// peek_version reads only the leading varint — no body decode.
assert_eq!(peek_version(&v1_bytes).unwrap(), 1);
assert_eq!(peek_version(&v2_bytes).unwrap(), 2);
```
The contract:
| Encoder version W | Decoder version K | Result |
|---|---|---|
| `W = K` | exact match | every field read normally |
| `W < K` | reading old wire | known fields read; `since > W` fields → `Default::default()` |
| `W > K` | reading new wire | known fields read; trailing body bytes silently skipped |
The third case is what makes append-only evolution work: a newer producer can ship a payload to an older consumer that doesn't know what was added. As long as new fields are appended (with `since = N`) and never reordered, older consumers read what they understand and drop the rest.
`#[pack_io(deprecated = N)]` retires a field — encoders at version `>= N` drop it; decoders reading payloads at version `< N` still read it normally. The field must remain in the struct declaration until the next MAJOR (`2.x`) — removing it outright is a wire-format-breaking change for that type.
Pulls in the new `schema` Cargo feature, which implies `derive`:
```toml
pack-io = { version = "0.5", features = ["schema"] }
```
### Wire format — additive bump 1.1 → 1.2
The new versioned-struct encoding ([`docs/WIRE_FORMAT.md §3.8`](https://github.com/jamesgober/pack-io/blob/main/docs/WIRE_FORMAT.md#38-versioned-structs)):
```
VersionedStruct ::= varint(version) varint(body_len) body
body ::= live_field_encoding * (concatenated, no padding)
```
- `version` is `u32` LEB128, starting at `1` (`0` is rejected).
- `body_len` is `u64` LEB128, validated against `Config::max_alloc` before allocation.
- `body` is the concatenated encodings of every field live at this version.
**Cross-version compatibility is wholly contained in the spec.** A decoder for type `T` at known version `K` reading a payload at wire version `W` follows the table above mechanically — no derive-internal state required, no negotiation handshake, no per-field framing.
This is **purely additive**. Plain (non-versioned) structs encode exactly as in v0.4; the choice between versioned and non-versioned is per-type, opt-in via the attribute. Every payload valid under spec 1.1 remains valid under spec 1.2.
### `peek_version` — runtime dispatch helper
```rust,ignore
pub fn peek_version(bytes: &[u8]) -> Result<u32>;
```
Reads only the leading `varint(version)` of a versioned payload and returns it, leaving the rest of the buffer untouched. Useful when a single transport carries payloads of multiple revisions and the dispatcher needs to pick the target type at runtime:
```rust,ignore
match pack_io::peek_version(&incoming).unwrap() {
1 => handle_v1(pack_io::decode::<MessageV1>(&incoming)?),
2 => handle_v2(pack_io::decode::<MessageV2>(&incoming)?),
other => tracing::warn!("unknown schema version {other}, dropping"),
}
```
### Feature freeze
The public API and feature surface are **frozen** as of this release. The roadmap from here:
- **v0.6 — Optimisation.** Profile the codec paths against `bincode` / `postcard` / `rkyv`; tighten varint loops, length-prefix handling, inlining. No new public types, traits, or wire-format changes — only the same surface running faster.
- **v0.7 — Hardening.** `cargo-fuzz` harness, hostile-input audit, cross-platform byte-equivalence verification. API frozen (already true from v0.5).
- **v0.8 → v0.9 — Alpha → Beta → RC.** Real consumers integrate; bug fixes only.
- **v1.0.0 — Stable.** API + wire format frozen for the entire 1.x line.
If a missing feature surfaces between now and 1.0, it ships in v2.0. From this release forward, the contract is stability.
### Publish-readiness fixes rolled in
The publish blockers we found between the v0.4.0 GitHub tag and a crates.io upload all ship in v0.5.0 too:
- `pack-io-derive/README.md` — the proc-macro crate now has its own README; `cargo publish` accepts the package.
- License files bundled into `pack-io-derive/` so the published `.crate` is self-contained.
- Six broken `[pack_io::*]` intra-doc links in `pack-io-derive/src/lib.rs` replaced with inline-code (the standalone derive crate has no dependency on `pack_io`, so the links never resolved on docs.rs).
- `required-features = ["derive"]` declared on the derive-using bench, examples, and integration tests in [`Cargo.toml`](https://github.com/jamesgober/pack-io/blob/main/Cargo.toml). Default-feature `cargo build` succeeds cleanly; rust-analyzer stops surfacing phantom "cannot find derive macro" errors.
- `pack-io-derive` proc-macros now declare `attributes(pack_io)` so `#[pack_io(...)]` is recognised as a helper attribute. (This is also what makes v0.5's schema attributes work — the same registration fix unblocks both.)
## Breaking changes
**None.** Every v0.4 source file compiles unchanged and every v0.4 wire payload decodes unchanged. The schema-evolution surface is opt-in via the `schema` feature; types without `#[pack_io(version = N)]` retain the v0.4 plain encoding.
## Verification
Run on Windows x86_64, Rust stable + 1.85 (MSRV); identical commands pass on Linux (WSL2 Ubuntu) and via the configured CI matrix on macOS:
```bash
cargo fmt --all -- --check # stable rustfmt
cargo +1.85 fmt --all -- --check # MSRV rustfmt (per the v0.4 process fix)
cargo clippy --all-targets --all-features -- -D warnings
cargo +1.85 clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo +1.85 test --all-features
cargo build --no-default-features # no_std build
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo audit
cargo deny check
```
All green on **both toolchains**. Test counts at this tag (stable, `--all-features`):
- **71** unit tests.
- **25** round-trip property tests.
- **21** determinism property tests.
- **20** adversarial decode tests.
- **19** collection tests.
- **11** streaming tests.
- **14** derive tests.
- **15** schema-evolution tests (new — v1↔v1 self-round-trip, v1→v2 with defaults, v2→v1 with trailing-bytes ignored, three-version deprecated-field decode in all directions, `peek_version` correctness, hostile body-length rejection, three proptest invariants).
- **23** doctests (one new doctest on `peek_version`).
- **219** total, every one passing.
All eight example programs run end-to-end. CI matrix on GitHub Actions covers Linux / macOS / Windows × stable / 1.85 — same six cells we shipped from v0.1.
## What's next
- **0.6.0 — Optimisation.** Profile the encode and decode paths. Tighten the varint loop, the length-prefix handling, the `Decode` trait dispatch path. Benchmark vs `bincode` / `postcard` / `rkyv` and post the numbers in the README. Goal: beat `bincode` on owning decode of borrow-heavy types and beat an `rkyv` archived read on equivalent zero-copy. No new public surface.
## Installation
```toml
[dependencies]
pack-io = { version = "0.5", features = ["schema"] }
# Without schema attributes (derive only):
pack-io = { version = "0.5", features = ["derive"] }
# Without derive (in-memory + streaming codec only):
pack-io = "0.5"
# no_std build:
pack-io = { version = "0.5", default-features = false }
```
MSRV: Rust 1.85 (2024 edition).
## Documentation
- [README](https://github.com/jamesgober/pack-io/blob/main/README.md)
- [API Reference](https://github.com/jamesgober/pack-io/blob/main/docs/API.md)
- [Wire Format Spec](https://github.com/jamesgober/pack-io/blob/main/docs/WIRE_FORMAT.md) (now v1.2)
- [CHANGELOG](https://github.com/jamesgober/pack-io/blob/main/CHANGELOG.md)
---
**Full diff:** [`v0.4.0...v0.5.0`](https://github.com/jamesgober/pack-io/compare/v0.4.0...v0.5.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/pack-io/blob/main/CHANGELOG.md#050---2026-06-04).