Versioning
DraftAny server may meet any client: an app months older than its server, a server older than a freshly updated app, a federated peer on code Capsule never shipped. Both sides hold data the other cannot recreate — the client its keys and unsynced edits, the server the only copy of uploaded ciphertext. This page fixes the rules that keep every pairing safe: reads stay compatible forever, writes fail closed, and nothing is ever dropped to resolve a mismatch.
System Invariants
Section titled “System Invariants”Every client and server implementation, of any version, MUST uphold these. Together they make the worst outcome of any version mismatch a refused write, never lost or corrupted data.
- Bytes are immutable and content-addressed. A blob is named by the hash of its bytes and never changes, so its identity does not depend on any protocol version (Transfer).
- Every signed record leads with its version, and unknown fields survive. A record’s schema version is decoded before anything else. Fields a reader does not know are kept verbatim, re-emitted canonically, and covered by the signature, so stripping one fails verification (Schema Rules).
- Never write what you do not understand. A component that meets a record version, enum
value,
crypto_suite_idorsidecar_schemaabove what it implements may read and store it verbatim, but MUST NOT modify, re-sign or re-encode it. Reads of any version it does understand stay supported forever. - The server never rewrites client-signed data. It stores and serves signed manifests and sealed blobs byte for byte. Data leaves the server only through a signed lifecycle action followed by garbage collection after a grace window.
- A client never discards data to resolve a mismatch. An entry it cannot apply is quarantined; a write it cannot send is queued; a catalog newer than the binary is refused and left untouched. Each resumes after the user updates.
- Concurrent edits converge deterministically. Metadata merges by CRDT (Metadata); album membership and moves are ordered by MLS commit order. No merge depends on wall-clock agreement between devices or on which side is newer.
- Local originals are released only on server custody: a durable verdict, a signed custody receipt and a matching read-back hash (Durability). A copy on another device, including a delegate, is never custody.
- One protocol version per binary. A client sends the same
protocol_versionon every request it makes. - Evolution is additive. New fields are optional and absent from the wire until set; new
behaviour is a new
protocol_version. A change no window can span goes through the album upgrade ceremony, never an in-place rewrite.
Version Identifiers
Section titled “Version Identifiers”| Identifier | Carried on | Owner |
|---|---|---|
protocol_version (YYYY-MM-DD) | every request, every album pin | Validation — Universal Headers |
crypto_suite_id | every manifest and metadata blob | Primitives |
sidecar_schema | every sidecar, key 0 | Metadata |
| provenance record version | every provenance record, leading field | Provenance |
| client build (semver) | X-Capsule-Min-Client-Build floor in every response | Validation — Universal Headers |
catalog PRAGMA user_version | the client’s SQLite catalog | below |
| server schema migrations | PostgreSQL seaql_migrations | Filesystem — Server |
Update Policy
Section titled “Update Policy”Servers update first. A server release accepts a closed window [Min, Max] of
protocol_version values and advertises it on every response. The window MUST include every
version spoken by an app release still inside its support period, so a staggered app rollout
never strands a user. A version leaves the window only through the
deprecation policy:
announced ahead, after which writes from it are refused with 426 and reads keep working.
Apps stay lenient. A native app must work against any server whose window contains its
version, and treats every unknown response field as preserved data, not an error. It refuses
to write — and tells the user to update — when the server answers 426 or advertises a minimum
client build above its own. The web client is the one exception: it is
served by its own server and assumes that server’s exact version.
Server upgrades are forward-only. Migrations run before serve (which refuses an
unmigrated schema), never downgrade, and are preceded by a backup. A failed migration means
restoring the backup, never rolling back and continuing.
Compatibility is checked once, early. Client and server check the window at the first
request and fail loudly rather than soft-degrade; past that handshake, both sides agree on
protocol_version, crypto_suite_id and sidecar_schema for the operation.
Client Catalog Migration
Section titled “Client Catalog Migration”The client catalog is the library as the user experiences it, so it has the server schema’s
obligation: a forward-only stepwise migrator keyed on PRAGMA user_version, one step per
version, no downgrade. Rebuilding from sidecars is the repair path for a known-inconsistent
index (Maintenance), not a migration strategy: it can
only return what the sidecars carry. The derived cache (thumbnails, previews) is dropped and
regenerated instead of migrated.
A catalog stamped above the binary’s version is refused with an error naming both versions, and left byte-for-byte untouched (invariant 5). An older binary writing to it would fill columns it does not know are authoritative; refusing costs the user an update.
Album Protocol Version Pinning
Section titled “Album Protocol Version Pinning”Each album declares a protocol_version at creation, immutable for its lifetime, and every
event in it must conform. A faulty implementation of a newer version can therefore only corrupt
albums of that version, which matters most under federation.
- A client below the server’s
Mincannot write to any album. - A client below an album’s pin cannot write to that album, even if the server still accepts its version elsewhere.
- Reads are never affected by either rule.
Album Upgrade Ceremony
Section titled “Album Upgrade Ceremony”An album moves to a newer version by tombstone plus fork: the old album freezes, a new album
at the target version is forked from its frozen state, and members move over. It is atomic per
client and resumable, and every step is keyed by an intent_id (UUIDv7) so a retried or
contradictory proposal can never produce two forks.
[v_old normal] --UpgradeIntent--> [v_old quiescing] --drain--> [v_old frozen] | AlbumTombstone commit v queued v_old writes replayed -> [v_new active]- Propose. An admin sends
UpgradeIntent { from_version, to_version, intent_id, proposer_device, deadline }, signed by its device key.deadlineis a duration; expiry is measured on the server’s clock. Only one intent may be live per album. - Quiesce. Members queue new writes locally, flagged with the
intent_id. The server refuses writes that do not name the liveintent_id(409 error.upload.album_quiescing). - Drain. The ceremony waits until no upload session against the album is in flight. On expiry it aborts by absence of state and queued writes return to the old album.
- Tombstone. The admin commits
AlbumTombstone { intent_id, frozen_state_hash }, a hash over the album’s members, accepted manifests and provenance head. Every member recomputes it; a mismatch aborts the upgrade for that member. - Fork. A new album at
to_versionreferences the existing ciphertext by content hash (no re-encryption); its manifests carryupgraded_from { old_album_id, intent_id, frozen_state_hash }in the signed core; fresh album keys are minted. - Replay. Each member re-encodes its queued writes for
to_versionand replays them. - Resume. A client that crashed mid-ceremony reads its local pending state and the album’s phase from the server and continues from there.
The server’s part is limited to what clients cannot do themselves: verifying the proposer
against the account’s device directory, the
expiry clock, the write filter and the drain count
(POST/GET/DELETE /v1/albums/{album_id}/upgrade). It never judges frozen_state_hash,
which is each member’s independent statement about its own view.
Validation
Section titled “Validation”Each invariant above has an automated test at the layer that owns it (Testing); the version-specific ones are:
- a request outside the window is refused with
426on every route class, and the client surfaces one typed upgrade error; - golden records and wire messages from every released protocol version still decode, verify and round-trip byte-identically, unknown fields included;
- an older reader handed a newer record stores it verbatim and refuses to modify it;
- a catalog at every historical
user_versionmigrates to the current one; a newer catalog is refused untouched; - the upgrade ceremony survives a crash after the tombstone without a second fork, and a divergent member aborts it cleanly with no queued write lost.