Validation Invariants
DraftRefuse-by-default checks every receiver runs before persisting a write, against every client class.
Server-Side Validation Invariants
Section titled “Server-Side Validation Invariants”Key-free structural checks. Canonical list; numbers are stable and cited elsewhere. Every rejection carries an error.* code (mapping) and is logged.
On POST /v1/upload (session creation)
Section titled “On POST /v1/upload (session creation)”- 1.
X-Capsule-Protocol∈ server[Min, Max]→ else426, no session. - 2.
crypto_suite_id∈ Primitives Inventory → else400. - 3.
hashlength = the suite’s digest size → else400. - 4.
size∈ (0,max_file_size] → else400/413. - 5.
content_type∈ the closed enum for this protocol version → else400. - 6.
album_idexists, caller has write capability, album’s pinnedprotocol_version= request’s → else403. - 7.
created_by_deviceis in the user’s device directory withadded_atbeforetimestamp→ else403. - 8.
timestampwithin the gross-drift bound (Timestamp Grammar); not an authorization control → else400.
On each PATCH /v1/upload/{id} chunk
Section titled “On each PATCH /v1/upload/{id} chunk”- 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 atsize(400), ≤ protocol max (413). - 11. Range end ≤ declared
size→ else400 size_exceeded, nothing written. - 12.
chunk_hashcomes from the requiredX-Capsule-Checksumand matches the body (missing or mismatch →400, nothing persisted).
At finalization
Section titled “At finalization”- 13. Committed set =
[0, size), the claim’s precondition. - 14. Recomputed ciphertext hash = declared
hash→ elseFailedProcessing. - 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 → else400. - 17.
prior_provenance_hash= last accepted manifest hash for theasset_id→ else409(stale revival). - 18.
amk_versionnever regresses per album and is within what the admin-signed MLS commit chain attests (Write Authorization) → else400. Acreaterefused 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.
On federation pull (server-to-server)
Section titled “On federation pull (server-to-server)”- 19. Capability verifies, unexpired,
jtiunrevoked (≤ 15 min cache), and recorded here for the member’s grant epoch → else401/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 requireprior_provenance_hashto equal the held head. - 18 holds per asset: an asset’s
amk_versionnever 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_cursorcarries a server-only MAC; forged →400. Clients separately enforcesync_seqmonotonicity (Sync). - 23.
DeviceDirectorypublish:directory_versionstrictly increases (else409) and the master signature verifies under the account’s identity anchor — the identity key from its first publish (X-Capsule-Identity-Key), immutable — else403(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_sigunder a non-revoked caller device (403);roster_versionstrictly greater than held or an identical replay,amk_epochnon-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→ else400, 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(never410); cumulative caps exhausted →409/413. - 27.
content_type∈ the link’s pinned closed enum → else400. - 28.
size∈ (0, linkmax_file_size] → else400/413. - 29. Owner quota admits the declared size at session creation (enforcement point) → else
403. - 30.
DropDescriptorwell-formed,kem_ctlength matches the suite; no album,amk_version, manifest, or provenance field → else400. - 31. Rate-limited per
{opaque_id}and per IP → else429. - 32. Adoption: the
createmanifest passes 1–8, 16–18, 25; references a blob in the caller’s own inbox;key_modeclosed. Inbox→album promotion and inbox-row delete are one transaction → else400/403/409, nothing written.
On custody receipts and signed attestations
Section titled “On custody receipts and signed attestations”- 33. A
CustodyReceiptis signed and persisted only inside the finalization transaction (both or neither);receipt_seqstrictly monotonic,prior_receipt_hashchained, never overwritten. BeforeCompleted→409. - 34. A
StorageAttestationechoes the clientnonceand signs the same state read that produced its verdict.
Client-Side Validation Invariants
Section titled “Client-Side Validation Invariants”Run before applying any received data; skipping one makes a client faulty.
verify_asseteveryAssetManifest; quarantine on failure.- Refuse a
sidecar_schemaabove the client’s max (write never; read only by opt-in read-only mode). - Refuse a
protocol_versionoutside the client’s[Min, Max]. - Refuse unknown closed-enum values; preserve unknown CBOR keys, never act on them.
- Keep
latest_provenance_hashper 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
durableverdict, and averify_assetaccept. User deletes are not gated. - Keep a per-user
directory_versionhigh-water mark; refuse and surface a regression. - Refuse an OR-set remove of an unobserved
add_id. - Refuse a
revoke_all_sessionswithout master-key proof. - Decode remote-origin bytes only in the sandboxed decoder.
Protocol and Capability Negotiation
Section titled “Protocol and Capability Negotiation”One fail-closed, one-shot gate on every versioned surface, before any state is written.
Universal Headers
Section titled “Universal Headers”| Header | Sent by | Meaning |
|---|---|---|
X-Capsule-Protocol | client/peer | YYYY-MM-DD protocol version |
X-Capsule-Crypto-Suite | client/peer on writes | suite id (inventory) |
X-Capsule-Sidecar-Schema | client (SDK: every request) | sidecar schema version |
X-Capsule-Protocol-Min/Max | server, every response | accepted protocol window |
X-Capsule-Min-Client-Build | server | semver 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).
Fail-Closed Rules
Section titled “Fail-Closed Rules”- 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/403layer; it never substitutes for the protocol header.
Idempotency Invariants
Section titled “Idempotency Invariants”Duplicates are no-ops; same key, different content is corruption. An unlisted surface is not idempotent.
| Surface | Key | Duplicate |
|---|---|---|
| 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 update | op 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 commit | Group commit chain (OpenMLS) | Rejected |
| Album upgrade / MLS re-key | intent_id | Never forks or re-keys twice |
| Device enrollment | Single-use enrollment code | Re-redemption rejected |
| Share/upload-link create | Client op id | Returns the minted link |
| Share/upload-link revoke | link_id | No-op |
| Drop adoption | drop_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 |
Atomicity Invariants
Section titled “Atomicity Invariants”- 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 areplaceapplies as one act when its manifest lands. - Stack edits: all
.tmpstaged, 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.