Skip to content

Validation Invariants

The cross-cutting refuse-by-default rules every Capsule receiver runs before persisting any incoming write. These are the operational core of the threat model — a server or client that skips one of them silently widens the blast radius for the entire client class taxonomy.

The server-side invariants are enforced in capsule-api (every write path passes through them); the client-side invariants are enforced via the single verify_asset chokepoint in capsule-core::crypto plus the per-receiver decoder paths. The protocol handshake is a one-shot pre-flight check on every request; idempotency and atomicity invariants are properties of specific write surfaces, each cross-linked to the doc that owns the surface.

The server holds no keys — it cannot verify any signature against a key it owns. But it does validate the structure of every write before persisting state. These checks are refuse-by-default and intentionally exhaustive; a buggy server that skips one of them silently widens the blast radius for the entire client class taxonomy.

This list is the canonical statement; Filesystem, Import, Federation, Authorization, and Authentication reference it without restating.

Invariants carry stable numbers (referenced across docs as “invariant 17”, “items 1–18”, etc.); they are grouped by write phase but the numbering is continuous.

  • 1. X-Capsule-Protocol is within the server’s [Min, Max] range. Otherwise 426 Upgrade Required, no session created.
  • 2. crypto_suite_id is a row of the Primitives Inventory. Otherwise 400.
  • 3. hash length matches the digest size for crypto_suite_id (32 bytes for SHA-256). Otherwise 400.
  • 4. size ∈ (0, max_file_size]. Otherwise 400 / 413.
  • 5. content_type ∈ closed enum for this protocol version. Otherwise 400.
  • 6. album_id exists; authenticated user has server-visible write capability on it; album’s pinned protocol_version equals the request’s. Otherwise 403.
  • 7. created_by_device is in the user’s published device directory, and the directory entry’s added_at precedes the request’s timestamp. Otherwise 403.
  • 8. timestamp passes a gross-drift sanity bound (default ±30 days of server clock, configurable). This is a non-security guard that surfaces a wildly-wrong honest client, not an authorization control — authorization and ordering ride the epoch and chain, and the server records its own trusted received_at as the authoritative time for time-based policy. The client timestamp is stored verbatim for audit. See Keys — Write Authorization. Otherwise 400.
  • 9. Offset is exactly the current received-byte count. Otherwise 409, with X-Capsule-Offset returned.
  • 10. Non-final chunk size is a multiple of 4 KiB. Otherwise 400.
  • 11. Cumulative received ≤ declared size. Otherwise 400 / 413, session moves to FailedProcessing.
  • 12. The (upload_id, offset, chunk_hash) idempotency tuple is new OR matches an exact prior PATCH. Otherwise (same offset, different hash) 409 + corruption error.
  • 13. Total received == declared size. Otherwise FailedProcessing.
  • 14. Recomputed ciphertext hash == declared hash. Otherwise FailedProcessing + corruption error.
  • 15. Manifest envelope re-validated (rerun 1–8) inside the finalization transaction.

On non-upload writes (lifecycle action manifest, metadata-update, derivative-add/replace, trash-restore)

Section titled “On non-upload writes (lifecycle action manifest, metadata-update, derivative-add/replace, trash-restore)”
  • 16. action is in the closed enum. Otherwise 400.
  • 17. prior_provenance_hash equals the last accepted manifest’s content hash for this asset_id. Otherwise 409 (stale-revival).
  • 18. amk_version is monotonic per album (never regresses) and within the range the album’s admin-signed MLS commit chain attests. The server’s stored counter is a structural backstop; the authoritative ceiling is MLS, so a server cannot fabricate a future epoch a client will honor — see Write Authorization. Otherwise 400.
  • 19. Capability token verifies under home server’s signing key; exp in future; jti not in revocation list (cached ≤ 15 min). Otherwise 401 / 403.
  • 20. All checks (1)–(18) re-applied — federation does not unlock looser rules.
  • 21. Per-peer rate budgets unbroken (events/hour, bytes/hour, CPU/hour). Otherwise 429.

On the /sync feed, directory publish, and federated reports

Section titled “On the /sync feed, directory publish, and federated reports”
  • 22. The sync_cursor carries a server MAC under a server-only key; a forged or mutated cursor is rejected (400). This is the authenticity layer; the client independently enforces per-album sync_seq monotonicity (client-side invariants below). Owner: Import — Download & Sync.
  • 23. A published DeviceDirectory has directory_version strictly greater than the version currently stored for that user, and the master signature covers it. A non-advancing or regressing publish is rejected (409). Owner: Cryptography — Device Directory.
  • 24. A federated report (an out-of-band moderation message, not a state write) carries a valid signature from the reporting server and is within that peer’s report rate budget; otherwise it is dropped before reaching the admin queue. Owner: Moderation — Federated Reporting.

Every rejection is logged with a structured reason code; the rejected hash is remembered (bounded, see Federation — Soft-Fail Semantics) so divergence between Capsule’s view and a permissive peer’s view is detectable.

Mirror checklist that every client implements before applying any received data — local or remote. A client that skips one of these is in the faulty class.

  • Run verify_asset on every received AssetManifest. Quarantine on failure; never silent-drop, never silent-accept.
  • Reject an incoming sidecar_schema greater than the client’s max_known_sidecar_schema. Refuse to write that sidecar; refuse to read in normal mode (read-only opt-in is allowed).
  • Reject an incoming protocol_version outside [Min, Max] known to the client. The same handshake the server runs.
  • Reject an unknown enum value for any field whose enum is closed at the current schema (notably action, content_type, gps.source, DerivativeManifest.role). Unknown CBOR map keys are preserved per Postel’s Law and never executed.
  • Maintain a local latest_provenance_hash per asset_id. Refuse to apply any manifest whose prior_provenance_hash is behind the local value. Surface it.
  • Maintain a per-user directory_version high-water mark. Refuse a DeviceDirectory whose directory_version is below it (a server attempting to roll back a revocation or hide a device); pin and surface the regression.
  • Reject an OR-set remove whose add_id was never observed locally as an add.
  • Refuse to follow a revoke_all_sessions confirmation that did not include a master-key proof.
  • Decode remote-origin asset bytes only in the sandboxed decoder.

Every versioned API surface — client-to-server uploads, sync feed, federation pull, peering — runs the same compatibility gate. The gate is fail-closed: a mismatch is a hard reject before any state is written, never a silent degrade.

HeaderSent byMeaning
X-Capsule-Protocolclient / peerYYYY-MM-DD protocol version the request is written against
X-Capsule-Crypto-Suiteclient / peer on writesu16 suite id from the Primitives Inventory
X-Capsule-Sidecar-Schemaclient on metadata-updateu16 schema version declared at sidecar_schema field 0
X-Capsule-Protocol-Minserver on every responsethe lowest protocol version this server accepts
X-Capsule-Protocol-Maxserver on every responsethe highest protocol version this server accepts
X-Capsule-Min-Client-Buildserver on responsessemver deprecation cutoff; advisory unless the path is hard-deprecated
  • X-Capsule-Protocol outside [Min, Max] on a write: 426 Upgrade Required. No session created, no row written.
  • X-Capsule-Crypto-Suite not in the inventory: 400 Bad Request.
  • X-Capsule-Sidecar-Schema above the server’s max known: 400 Bad Request. (The server does not parse sidecars itself, but it refuses to acknowledge writes whose schema number it does not index.)
  • Reads of any past version succeed. Read invariants are deliberately stable per Versioning, so a current server still serves v_{k-N} blobs from years ago.
  • Federation capability is an additional 401 / 403 layer on top of the protocol gate. A valid token never substitutes for a valid protocol header.

The handshake is one-shot per request, not a negotiation. Either both sides agree by inspection, or the request fails. There is no back-and-forth that could leak partial state.

Every write surface has a single idempotency key. Duplicates are no-ops; conflicts (same key, different content) are corruption errors.

SurfaceIdempotency keyDuplicate behavior
Upload chunk (PATCH /upload/{id})(upload_id, offset, chunk_hash)Returns current offset; no double-write
Session creation (POST /upload)(owner_id, hash, album_id) — server’s existing dedup checkReturns the existing session; no second session
Lifecycle manifest write(asset_id, prior_provenance_hash, manifest_hash)No-op append; chain advances exactly once
Metadata-update operationOperation id (UUIDv7) + (asset_id, prior_provenance_hash)Re-applying the same op is structurally identical
Federation capability proof(peer_id, jti)Refresh with same jti returns the same response
Federation pull(peer_id, sync_cursor) — the sync cursor itself is the keyRe-pull returns the same page
MLS commitHandled by OpenMLS; commits are ordered by the group’s commit chainOpenMLS rejects duplicates
Album upgrade ceremonyintent_id (UUIDv7); see VersioningSame intent never produces two forks

A write surface that does not appear here is, by default, not idempotent and must be designed before it ships.

Multi-write operations that must succeed-as-one or not at all. A partial success on any of these is itself a damage scenario.

  • Asset bundle finalization. The manifest, ciphertext blob, metadata blob, and provenance blob commit together in a single Postgres transaction. Server failure between any pair leaves the entire bundle un-finalized; the session moves to FailedProcessing and the partial blobs are GC’d. (Filesystem — Atomic Writes)
  • Stack edits. All affected sidecars stage as .tmp files first; renames happen together. Any rename failure discards every .tmp in the bundle. (Filesystem — Atomic Writes)
  • AMK epoch bump + write-tier key rotation. A new AMK and a new write-tier key are minted as a single MLS commit. The two cannot exist out of sync.
  • Album upgrade ceremony. The cutover is one MLS commit, the AlbumTombstone. Until applied, the client is in v_old; after, in v_new. (Versioning — Album Upgrade Ceremony)
  • Lifecycle manifest + provenance record. Writing a lifecycle manifest and appending its provenance entry are the same act, because the provenance entry is the manifest plus the chain link. There is no separate “now record provenance” step that can race.