Skip to content

Validation Invariants

Draft

Refuse-by-default checks every receiver runs before persisting a write, against every client class.

Key-free structural checks. Canonical list; numbers are stable and cited elsewhere. Every rejection carries an error.* code (mapping) and is logged.

  • 1. X-Capsule-Protocol ∈ server [Min, Max] → else 426, no session.
  • 2. crypto_suite_id ∈ Primitives Inventory → else 400.
  • 3. hash length = the suite’s digest size → else 400.
  • 4. size ∈ (0, max_file_size] → else 400/413.
  • 5. content_type ∈ the closed enum for this protocol version → else 400.
  • 6. album_id exists, caller has write capability, album’s pinned protocol_version = request’s → else 403.
  • 7. created_by_device is in the user’s device directory with added_at before timestamp → else 403.
  • 8. timestamp within the gross-drift bound (Timestamp Grammar); not an authorization control → else 400.
  • 9. The range is admitted against the committed set and the in-flight ranges before any byte is written (admission): disjoint → written and committed; wholly committed → duplicate, nothing written; any other overlap → 409 range_conflict; over the session’s in-flight cap → 409 too_many_in_flight; past the fragment bound → 409 too_fragmented.
  • 10. Well-shaped chunk: application/octet-stream (415), non-empty, offset 4 KiB-aligned and length 4 KiB-aligned unless it ends at size (400), ≤ protocol max (413).
  • 11. Range end ≤ declared size → else 400 size_exceeded, nothing written.
  • 12. chunk_hash comes from the required X-Capsule-Checksum and matches the body (missing or mismatch → 400, nothing persisted).
  • 13. Committed set = [0, size), the claim’s precondition.
  • 14. Recomputed ciphertext hash = declared hash → else FailedProcessing.
  • 15. 1–8 re-run inside the finalization transaction.

In the index critical section (every manifest write, upload or lifecycle)

Section titled “In the index critical section (every manifest write, upload or lifecycle)”

Enforced inside the critical section that mints the sequence number, for lifecycle writes and upload replace — at the gate, concurrent replaces could both pass.

  • 16. action ∈ closed enum → else 400.
  • 17. prior_provenance_hash = last accepted manifest hash for the asset_id → else 409 (stale revival).
  • 18. amk_version never regresses per album and is within what the admin-signed MLS commit chain attests (Write Authorization) → else 400. A create refused here was never published; its client re-seals it at the album’s current epoch and retries once (Stale-Epoch Create). A device that does not yet hold that epoch’s key cannot, and keeps the asset local until the key arrives.
  • 19. Capability verifies, unexpired, jti unrevoked (≤ 15 min cache), and recorded here for the member’s grant epoch → else 401/403.
  • 20. 1–18 and 25 re-applied to every pulled entry, each as strict as the compacted feed lets a receiver check it. Open: #964 (no inbound federated writes yet). Where the feed cannot show what the write-time rule reads, the pull applies it as follows:
    • 7 is left to the receiving client, which holds the device directory a capability does not reach.
    • 17 refuses a manifest behind the held head and a second create. The feed serves only an asset’s current manifest, so a pull cannot require prior_provenance_hash to equal the held head.
    • 18 holds per asset: an asset’s amk_version never regresses along its own chain. The feed re-mints an asset’s position without a new record and replays from zero after a restart, so an honest feed serves an older epoch’s record after a newer one. The receiving client checks the epoch against the MLS commit chain.
    • 25: a record that binds a metadata blob is served with exactly that blob. A record that binds none never replaces a metadata blob this server holds. Only a signed binding record proves a newer blob.
  • 21. Per-peer budgets (events, bytes, CPU per hour), keyed on peer origin not token → else 429. Open: #964 (CPU unmetered; it is the pull worker’s).

On the sync feed, directory publish, and federated reports

Section titled “On the sync feed, directory publish, and federated reports”
  • 22. sync_cursor carries a server-only MAC; forged → 400. Clients separately enforce sync_seq monotonicity (Sync).
  • 23. DeviceDirectory publish: directory_version strictly increases (else 409) and the master signature verifies under the account’s identity anchor — the identity key from its first publish (X-Capsule-Identity-Key), immutable — else 403 (Device Directory).
  • 24. Federated report: signature verifies under the peer’s pinned key before its budget is charged; unpinned peer → 403 (Federated Reporting).

On PUT /v1/albums/{album_id}/roster (album roster publish)

Section titled “On PUT /v1/albums/{album_id}/roster (album roster publish)”
  • 33. Canonical, bounded CBOR naming this album, no duplicates, owner excluded (400); caller owns the album (404); attester_sig under a non-revoked caller device (403); roster_version strictly greater than held or an identical replay, amk_epoch non-regressing (409); at most 16 above held, so membership cannot be frozen (400). Omitted members are marked revoked, never deleted. Owner: Authorization.

On any write whose bundle carries a metadata blob

Section titled “On any write whose bundle carries a metadata blob”
  • 25. The metadata blob’s content hash = the manifest’s metadata_blob_hash → else 400, nothing written. (Metadata Equivalence).

On POST /d/{opaque_id} (upload-link drop session) and adoption

Section titled “On POST /d/{opaque_id} (upload-link drop session) and adoption”

A drop has no manifest, so these replace 1–8; chunks and finalization reuse 9–14.

  • 26. Link is live (exists, unexpired, unrevoked) → else indistinguishable 404 (never 410); cumulative caps exhausted → 409/413.
  • 27. content_type ∈ the link’s pinned closed enum → else 400.
  • 28. size ∈ (0, link max_file_size] → else 400/413.
  • 29. Owner quota admits the declared size at session creation (enforcement point) → else 403.
  • 30. DropDescriptor well-formed, kem_ct length matches the suite; no album, amk_version, manifest, or provenance field → else 400.
  • 31. Rate-limited per {opaque_id} and per IP → else 429.
  • 32. Adoption: the create manifest passes 1–8, 16–18, 25; references a blob in the caller’s own inbox; key_mode closed. Inbox→album promotion and inbox-row delete are one transaction → else 400/403/409, nothing written.

On custody receipts and signed attestations

Section titled “On custody receipts and signed attestations”
  • 33. A CustodyReceipt is signed and persisted only inside the finalization transaction (both or neither); receipt_seq strictly monotonic, prior_receipt_hash chained, never overwritten. Before Completed → 409.
  • 34. A StorageAttestation echoes the client nonce and signs the same state read that produced its verdict.

Run before applying any received data; skipping one makes a client faulty.

  • verify_asset every AssetManifest; quarantine on failure.
  • Refuse a sidecar_schema above the client’s max (write never; read only by opt-in read-only mode).
  • Refuse a protocol_version outside the client’s [Min, Max].
  • Refuse unknown closed-enum values; preserve unknown CBOR keys, never act on them.
  • Keep latest_provenance_hash per asset; refuse and surface a manifest behind it.
  • Persisted sidecar MUST equal the decrypted blob byte-for-byte, blob hash = metadata_blob_hash; else quarantine (Metadata Equivalence).
  • Verify and keep every upload’s CustodyReceipt; failure → write unconfirmed.
  • Discarding the only copy of irreplaceable bytes requires a verified receipt, a durable verdict, and a verify_asset accept. User deletes are not gated.
  • Keep a per-user directory_version high-water mark; refuse and surface a regression.
  • Refuse an OR-set remove of an unobserved add_id.
  • Refuse a revoke_all_sessions without master-key proof.
  • Decode remote-origin bytes only in the sandboxed decoder.

One fail-closed, one-shot gate on every versioned surface, before any state is written.

HeaderSent byMeaning
X-Capsule-Protocolclient/peerYYYY-MM-DD protocol version
X-Capsule-Crypto-Suiteclient/peer on writessuite id (inventory)
X-Capsule-Sidecar-Schemaclient (SDK: every request)sidecar schema version
X-Capsule-Protocol-Min/Maxserver, every responseaccepted protocol window
X-Capsule-Min-Client-Buildserversemver deprecation cutoff (advisory)

Census of X-Capsule-*: new headers MUST register here. By pointer: X-Capsule-Offset, -Committed, -Content-Length, -Upload-Status, -Upload-Error, -Checksum (required on PATCH), -Session-Expires-In, -Session-Idle-Expires-In (Transfer — Upload); X-Capsule-Advice (Network Resilience — Server Advice); X-Capsule-Identity-Key (required on directory publish; invariant 23). cohort_hash is deliberately a body field (Device Cohorts).

  • Protocol outside [Min, Max] on a write → 426. Reads of any past version succeed (Versioning).
  • Unknown crypto suite or sidecar schema above max → 400.
  • Federation capability is an extra 401/403 layer; it never substitutes for the protocol header.

Duplicates are no-ops; same key, different content is corruption. An unlisted surface is not idempotent.

SurfaceKeyDuplicate
Upload chunk(upload_id, byte range)Wholly committed → the session’s state, nothing written, bytes not compared and never written (a changed source is stopped by the client’s chunk-tag guard); partial overlap → 409 error.upload.range_conflict
Session creation(owner_id, hash, album_id)Active session returned; finalized → 409 error.upload.duplicate_blob + existing asset
Lifecycle manifest(asset_id, prior_provenance_hash, manifest_hash)Chain advances once
Metadata updateop id + (asset_id, prior_provenance_hash)Structurally identical
Federation capability proof(peer_id, jti)Same response
Federation pull(peer_id, sync_cursor)Same page
MLS commitGroup commit chain (OpenMLS)Rejected
Album upgrade / MLS re-keyintent_idNever forks or re-keys twice
Device enrollmentSingle-use enrollment codeRe-redemption rejected
Share/upload-link createClient op idReturns the minted link
Share/upload-link revokelink_idNo-op
Drop adoptiondrop_id (invariant 32)Returns the promoted asset
Album roster publish(album_id, roster_version) (invariant 33)Identical bytes → 200 replayed; different → 409 error.album.roster_stale
  • Blob finalization: each blob of a bundle finalizes on its own, and its index rows and custody receipt commit in one transaction; failure → FailedProcessing, partials GC’d (Atomic Writes). A bundle becomes visible only once its provenance and metadata blobs have finalized (visibility gate), and a replace applies as one act when its manifest lands.
  • Stack edits: all .tmp staged, renamed together; any failure discards the bundle.
  • AMK epoch bump + write-tier rotation: one MLS commit.
  • Album upgrade: one MLS commit (AlbumTombstone) (Versioning).
  • Lifecycle manifest + provenance: the same act — the entry is the manifest plus its chain link.