Versioning
Changes are inevitable. Capsule minimizes breaking changes but generously accepts compatible ones. The aim is backward-compatible reads forever and a deliberately fail-closed write path — a version-mismatched client never silently corrupts state; it is rejected at the handshake.
The enforcement is cross-cutting: every wire request, every album commit, and every sidecar carries a version identifier. The header set below is the contract that lets two implementations agree (or fail-closed) without negotiating. Album pinning is implemented in the album metadata model (capsule-api + capsule-core); the upgrade ceremony is an MLS application-layer flow in capsule-core::crypto::mls driven by client UI. The min-supported-client window is enforced server-side in capsule-api.
Versioned Surfaces
Section titled “Versioned Surfaces”Versioning happens on multiple layers, each owned by the doc that defines it:
- Metadata CBOR schema —
sidecar_schemafield 0 of every sidecar (see Metadata — Schema Versioning Rules). - Cryptographic primitive bundle —
crypto_suite_idon every manifest and metadata blob (see Cryptography — Versioning Identifiers). - Wire protocol —
protocol_version(date-based,YYYY-MM-DD) on every API request and album pin. See Threat Model — Protocol Negotiation for the universal handshake. - Client cache — internal and rebuildable; cache schema changes drop and rebuild rather than migrate.
- Server data structures — PostgreSQL schema migrations forward-only. The session-state store is a deployment choice, not a versioned API surface (see Filesystem — Server: Deployment Profiles).
Negotiation Headers
Section titled “Negotiation Headers”The contract for version compatibility — every API request and response carries these. The full fail-closed rule set is owned by Threat Model — Protocol and Capability Negotiation.
| Header | Sent by | Meaning |
|---|---|---|
X-Capsule-Protocol | client / peer | YYYY-MM-DD protocol version the request is written against |
X-Capsule-Crypto-Suite | client / peer on writes | u16 suite id from the Primitives Inventory |
X-Capsule-Sidecar-Schema | client on metadata-update | u16 schema version declared at sidecar_schema field 0 |
X-Capsule-Protocol-Min | server on every response | the lowest protocol version this server accepts |
X-Capsule-Protocol-Max | server on every response | the highest protocol version this server accepts |
X-Capsule-Min-Client-Build | server on responses | semver deprecation cutoff; advisory unless the path is hard-deprecated |
Compatibility Verification
Section titled “Compatibility Verification”Initial startups of a client and server always strictly check for version compatibility and crash early rather than soft-degrade. The single handshake in Threat Model — Protocol and Capability Negotiation is the only point at which compatibility is determined; once an operation is past the handshake, both sides know they agree on protocol_version, crypto_suite_id, and sidecar_schema.
Capsule does not support backwards migrations or version downgrades. Server-side schema migrations are forward-only; if a migration fails, the server refuses to start and the operator restores from backup. There is no “rollback then continue” — that path is what corrupts data.
Album Protocol Version Pinning
Section titled “Album Protocol Version Pinning”Each album declares a protocol version at creation, and that version is immutable for the album’s lifetime. Every event in the album must conform to it. Adopting a new protocol feature does not mutate an existing album — it requires either creating a new album, or an explicit upgrade ceremony that tombstones the old album and creates a new one.
This bounds the blast radius of a buggy or malicious implementation: a faulty v4 implementation can only ever corrupt v4 albums, because v1–v3 validation rules never change. It matters most under Federation, where Capsule cannot assume a peer is running the same version — pinning is what lets old albums keep working when a peer ships bad v4 code.
Album Upgrade Ceremony
Section titled “Album Upgrade Ceremony”A version-pinned album is upgraded by a tombstone-plus-fork ceremony: the old album is frozen, a new album at the target version is forked from its frozen state, and all members migrate. The ceremony is atomic at the user level — there is no halfway state visible to one client — and resumable if any participant crashes partway through. Every step is keyed by an intent_id: UUIDv7 to defeat duplicate or contradictory upgrade proposals.
[v_old normal] --UpgradeIntent--> [v_old quiescing] --drain--> [v_old frozen] | AlbumTombstone commit | v [v_new active] ^ queued v_old writes replayed- Freeze proposal. An album admin issues an MLS application message
UpgradeIntent { from_version, to_version, intent_id, proposer_device, deadline }, hybrid-signed by the admin’s DSK. The proposal carries a deadline (default 7 days). Any member’s client receiving anUpgradeIntentfor an album that is already in upgrade quiescence under a differentintent_idrejects the new proposal — only one upgrade can be in flight per album. - Quiesce writes. Members enter upgrade quiescence on receipt of
UpgradeIntent:- In-flight uploads against the album are allowed to reach a terminal state.
- New writes are queued locally with a
pending_until_upgradeflag and theintent_id; they are not sent to the server. - The server augments the album row with
upgrade_pending_to = to_version, intent_id. New upload sessions for this album whosemanifest.intent_iddoes not match are rejected with409 Conflict— preventing a stale v_old client from writing past the freeze.
- Drain. The upgrade cannot proceed while any session for this album is in
UploadingorWaitingForProcessing. The server exposes the in-flight count to the proposer’s client. The deadline from step 1 bounds the wait; on deadline expiry the upgrade aborts cleanly (state returns to v_old normal; queued local writes are flushed back to v_old). - Tombstone. Once drained, the proposing admin issues an MLS commit
AlbumTombstone { intent_id, frozen_state_hash }.frozen_state_hashis a SHA-256 over the canonical CBOR of the album’s full state: the sorted member list, every accepted manifest’s hash, and the head of the album’s provenance log. Every receiving member’s client recomputes the hash against its own state; on mismatch the upgrade aborts (each member independently — the album returns to normal operation). Hash mismatch means at least one member’s view of the album diverges and must be resolved before any upgrade. - Fork. A new album group is created at
to_version, MLS-namedparent_id_v{n}, with the manifest fieldupgraded_from: { old_album_id, intent_id, frozen_state_hash }. Assets are not re-encrypted: the new album references the existing ciphertext blobs by content hash. Members are added to the new MLS group via standardAddproposals; freshAMK_v1and a fresh write-tier key are minted. - Apply queued writes. Each member’s locally queued
pending_until_upgradewrites are re-encoded againstto_version(the album pin andcrypto_suite_idmay have changed) and replayed into the new album. - Resumption (partial-failure recovery). A client that crashes between step 2 and step 6 reads its local
upgrade_pending_toon restart, queries the server for the upgrade’s current phase via the album row, and resumes from there. Theintent_idis the idempotency key — the sameUpgradeIntentnever produces two forks, and a duplicateAlbumTombstonecommit is a no-op at the MLS layer. - Atomicity guarantee. The cutover is the single MLS commit in step 4. Until that commit is applied by a member’s client, the client is operating in v_old; after, in v_new. There is no in-between state visible to one client. Cross-member, the cutover is observed as each member processes the commit; until the slowest member processes it, that member is still in v_old (and its
pending_until_upgradewrites remain queued locally, never lost).
What This Defends Against
Section titled “What This Defends Against”- Version-mismatched-client damage. A v_old client cannot write into a v_new album because every write carries
protocol_version, which is rejected by the protocol handshake and the server-side validation invariants. - Partial-upgrade corruption. Quiescence + drain ensures no v_old write is mid-flight at the moment of cutover. The
intent_idkeys every step so a retried, duplicated, or contradictory proposal cannot produce two divergent v_new albums. - Hostile member sabotage. A member whose computed
frozen_state_hashdiffers from the proposer’s rejects the tombstone, aborting the upgrade. A malicious member cannot trick the rest into a forged “post-upgrade” state.
The full atomicity rule lives in Threat Model — Atomicity Invariants; stranded pending_until_upgrade writes are a quarantine surface.
Min-Supported-Client Window
Section titled “Min-Supported-Client Window”The server accepts a window of past protocol_version values, not only the newest, so a staggered client rollout keeps working. A version leaves the window only after a deprecation period; the policy is owned by Threat Model — Min-Supported-Client Deprecation Policy.
The interaction with album pinning:
- A client whose
protocol_versionfalls below the server’sMinis rejected at the handshake for any write — it cannot upload into any album, including ones pinned to the version it can still parse. - A client whose
protocol_versionfalls below an album’s pin is rejected for writes to that album — the album’s pin is a per-album minimum, often higher than the server’s minimum (e.g., a v_2024-09-01 album rejects v_2024-06-01 clients even on a server that still accepts v_2024-06-01 for other albums). - Reads are unaffected. A v_old client can always read an album it cannot write to. The deprecation policy never makes historical state unreadable.
Validation
Section titled “Validation”- Handshake fail-closed (unit, both sides). Client-side: send a request with
X-Capsule-Protocoloutside the server-advertised range; assert refusal and structured error surfacing in the UI. Server-side: receive such a request; assert426response with the supported range in headers. - Album pin immutability (unit). Attempt to write into an album with a
protocol_versionother than the pin; assert rejection at the server envelope. - Upgrade ceremony idempotency (smoke). Run the 8-step ceremony against a multi-member testcontainer setup. Inject a crash after step 4 (the tombstone commit); resume; assert the same
intent_idproduces no second fork. Inject a divergent member state before step 4; assert the abort path triggers cleanly. - Stranded write queue (smoke). During quiescence, a member writes; the write is queued locally; the upgrade completes; the queued write is re-encoded against v_new and replayed. Assert no write is lost.
- Deprecation cutoff (unit). Mock the cutoff date past; assert a request from a now-deprecated client returns
426and the well-known announcement is served.
The cross-module case — full upgrade ceremony exercised through a real client UI + server + MLS group — is one bounded E2E test in Module Map.