Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the release version, and per-plane protocol versions enforced on the wire so incompatible peers fail fast and legibly instead of via silent decode/signature errors. ⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced dopedart) can no longer connect, by design, and now fails at the handshake. - New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO, the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN, and versioned_topic(). Unit tests assert ALPN/domain strings match their integer versions (no silent drift) + that topic namespacing is deterministic. - Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1). - Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip versions never share a swarm; the raw topic_id stays the room identity and what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs. - Cargo.toml 0.1.0 -> 0.2.0. 316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump rules, the "I changed X -> what do I bump" table, and a release checklist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
7.0 KiB
Markdown
140 lines
7.0 KiB
Markdown
# PeerSpeak Versioning Standard
|
|
|
|
PeerSpeak is a full-mesh P2P voice app. Its "API contract" is not a library
|
|
surface — it is the **wire protocol** two nodes use to talk. So versioning here
|
|
tracks one question above all others:
|
|
|
|
> **Can a node on build X talk to a node on build Y?**
|
|
|
|
There are two distinct version layers. Keep them straight.
|
|
|
|
---
|
|
|
|
## Layer 1 — Release version (`Cargo.toml`)
|
|
|
|
The human-facing label you put on a build ("install this one").
|
|
|
|
**Scheme: SemVer, pre-1.0 (`0.MINOR.PATCH`).**
|
|
|
|
While we are pre-1.0 (friends-only, no stability promise yet):
|
|
|
|
| Change | Bump | Example |
|
|
| --- | --- | --- |
|
|
| **Breaking wire/protocol change** — peers on the old build can no longer interoperate; *everyone must update* | **MINOR** | `0.4.2 → 0.5.0` |
|
|
| Compatible change — bug fix, internal refactor, or a feature that does **not** change the wire (UI, local-only behavior, additive logic that old peers ignore safely) | **PATCH** | `0.4.2 → 0.4.3` |
|
|
|
|
- **Reaching `1.0.0`:** when PeerSpeak is first shared beyond the trusted-friends
|
|
circle (a "public" release), and we are willing to commit to wire stability.
|
|
After 1.0, MAJOR = wire break, MINOR = compatible feature, PATCH = fix (normal
|
|
SemVer).
|
|
- Bump `version` in `Cargo.toml` as part of the change that warrants it, in the
|
|
same commit. The number in `Cargo.toml` is the source of truth; surface it in
|
|
the UI (e.g. an About/Settings line) so a user can read their build.
|
|
|
|
**Rule of thumb:** if you find yourself writing "all peers must rebuild" or
|
|
"breaking gossip wire change" in a commit message (as S2 and W4 did), that is a
|
|
**MINOR** bump, and it must also bump the relevant protocol version in Layer 2.
|
|
|
|
---
|
|
|
|
## Layer 2 — Protocol compatibility (the one that actually breaks calls)
|
|
|
|
Wire incompatibility must **fail fast and legibly** — never as a silent
|
|
signature/decode error that looks like a bug or an attack. We achieve this by
|
|
embedding a protocol version into each transport plane, so incompatible peers
|
|
are rejected at connect/subscribe time instead of mid-conversation.
|
|
|
|
PeerSpeak has **three independent planes**, each versioned **separately** — bump
|
|
only the plane whose wire format actually changed (audio rarely changes; gossip
|
|
changes often; they must not be forced to bump together).
|
|
|
|
### ALPN naming convention
|
|
|
|
All peerspeak ALPNs use the form **`peerspeak/<plane>/<N>`** where `<N>` is that
|
|
plane's protocol version (an integer, starts at `1`). iroh refuses a connection
|
|
whose ALPN does not match exactly, so two peers on different `<N>` for a plane
|
|
simply cannot open that connection → we map that to a clean "peer is running an
|
|
incompatible version" instead of garbage.
|
|
|
|
| Plane | ALPN / mechanism | Bump when… |
|
|
| --- | --- | --- |
|
|
| **Audio** | ALPN `peerspeak/audio/<N>` | the Opus/datagram framing, sequencing, or audio-handshake changes |
|
|
| **Friends/presence** | ALPN `peerspeak/friends/<N>` | the `ControlMsg` / presence ping-pong shape changes |
|
|
| **Gossip** | *(see below — cannot use a custom ALPN)* | `GossipPayload` / `GossipMessage` / `PeerState` shape, signing, or freshness rules change |
|
|
|
|
### Gossip is special
|
|
|
|
The gossip plane runs over **iroh-gossip's own `GOSSIP_ALPN`**, which we do not
|
|
control, so we cannot version it via the ALPN. Instead, the gossip protocol
|
|
version is bound in **two** places:
|
|
|
|
1. **Topic namespacing (primary, fail-fast):** the room's `topic_id` is a random
|
|
32 bytes carried in the ticket, but the topic we actually *subscribe* to is
|
|
`protocol::versioned_topic(topic_id)` — a deterministic, dependency-free
|
|
transform that folds `GOSSIP_PROTO` into the bytes. Peers on different gossip
|
|
versions therefore derive **different subscription topics from the same ticket**
|
|
and never share a swarm — the same isolation a versioned ALPN gives the other
|
|
planes. The ticket format and the room identity (`topic_id`) are unchanged; only
|
|
the subscribed topic is namespaced. (The transform is for *isolation*, not
|
|
security — cryptographic separation is the signature domain below.)
|
|
2. **Signature domain (cryptographic separation):** the signing domain string
|
|
(`peerspeak-gossip-v<N>`, bound into every signed payload) carries the version,
|
|
so two versions that somehow met on a topic would fail each other's verification
|
|
rather than misread it.
|
|
|
|
Bumping the gossip version = bump `protocol::GOSSIP_PROTO` (drives
|
|
`versioned_topic`) **and** `protocol::GOSSIP_SIG_DOMAIN` together (a unit test in
|
|
`protocol.rs` asserts the domain string matches `GOSSIP_PROTO`, so they can't drift).
|
|
|
|
### Single source of truth for protocol versions
|
|
|
|
All protocol versions, ALPNs, the gossip signature domain, and `versioned_topic`
|
|
live in **`src/protocol.rs`**. Every call site derives from there (e.g.
|
|
`crate::protocol::AUDIO_ALPN`); **never hand-write an ALPN literal inline.** A
|
|
unit test asserts each ALPN/domain string matches its integer version so a bump
|
|
can't half-apply.
|
|
|
|
---
|
|
|
|
## "I changed X — what do I bump?" (quick reference)
|
|
|
|
| You changed… | Layer 2 (plane version) | Layer 1 (`Cargo.toml`) |
|
|
| --- | --- | --- |
|
|
| Opus framing / audio datagram layout | `peerspeak/audio/N` → `N+1` | MINOR |
|
|
| `ControlMsg` / presence shape | `peerspeak/friends/N` → `N+1` | MINOR |
|
|
| `GossipPayload`/`PeerState`/signing | `GOSSIP_PROTO_VERSION` + sig domain → next | MINOR |
|
|
| UI, local config, recording, a fix that doesn't touch any wire | nothing | PATCH |
|
|
| An *additive* gossip field that old peers safely ignore | judgement call — if old peers misbehave without it, treat as breaking (MINOR + gossip bump); if truly ignorable, PATCH | PATCH or MINOR |
|
|
|
|
When in doubt about "is this additive-safe?", assume **breaking** and bump. A
|
|
false MINOR bump costs a coordinated rebuild; a false PATCH costs silent broken
|
|
calls in the field.
|
|
|
|
---
|
|
|
|
## Release checklist (per build handed to anyone)
|
|
|
|
1. Decide MINOR vs PATCH from the table above; bump `Cargo.toml`.
|
|
2. If MINOR for a wire reason, confirm the matching Layer-2 plane version(s) were
|
|
bumped in the same change.
|
|
3. Note the version + "breaking?" in the commit / handoff.
|
|
4. Tag the commit (`v0.x.y`) so a given binary maps to a known commit.
|
|
5. Rebuild **every** peer that must interoperate (e.g. dopedart, staged friend
|
|
releases) when the bump was a MINOR/wire break.
|
|
|
|
---
|
|
|
|
## Current baseline (standard adopted + migrated, 2026-06-18, `0.2.0`)
|
|
|
|
- `Cargo.toml`: **`0.2.0`** — the MINOR bump for the (deliberately breaking)
|
|
migration to this standard. **All peers must run ≥ `0.2.0` to interoperate**
|
|
(the ALPNs and gossip topics changed); the pre-standard `0.1.0`-era build
|
|
(e.g. an un-resynced dopedart) cannot talk to a `0.2.0` peer — by design, and it
|
|
now fails cleanly at the handshake instead of silently.
|
|
- Protocol versions (all at `1`): `peerspeak/audio/1`, `peerspeak/friends/1`,
|
|
gossip `peerspeak-gossip-v1` + `versioned_topic`. All sourced from
|
|
`src/protocol.rs`.
|
|
- **Remaining nicety (not blocking):** surface `env!("CARGO_PKG_VERSION")` in the
|
|
UI (an About/Settings line) and/or log it at startup, so a running build is
|
|
self-identifying in the field. Small follow-up.
|