Skip to content

Quota

Draft

Storage quota in Capsule is accounted to upload_user_id (the authenticated uploader), which is distinct from owner_id (the asset’s owner). This separation lets a user upload on behalf of a different owner (with verified permission) while keeping storage cost attributed correctly. The accounting model is enforced at the server filesystem and at upload session creation; this doc owns the threshold model and what happens when limits are hit.

Implementation will live in the planned capsule-server::quota module. Accounting reads from the Postgres asset index (size sums per upload_user_id); enforcement runs at session creation, before any chunks are accepted.

quota_used(user) = SUM(ciphertext_size) for all blobs where upload_user_id = user
+ SUM(metadata_blob_size)
+ SUM(derivative_blob_size for derivatives the user generated)

Notable:

  • Content-addressed dedup is global. A blob shared between two uploaders counts against only the first uploader — the second is a merge (see Upload Protocol — Deduplication and Merge). This is what stops a malicious user from racking up another user’s quota by re-uploading their public assets.
  • Derivatives count. Thumbnails and previews are real storage, attributed to whichever device generated them.
  • Provenance blobs count. Each per-asset .provenance.cbor (server-side encrypted blob) is small but accumulates.
  • Federated-received blobs count against the receiver. When a user’s home server caches a blob pulled from a federated peer on that user’s behalf, the cached bytes count against the receiving user’s quota, deduped by content hash so a blob the server already holds is never counted twice. A per-(receiving_user, source_peer) caching budget (deployment-configurable; default 25% of the receiver’s hard quota per source peer) bounds how much one user can pull from any single peer, so a user receiving from many peers cannot push the home server’s storage past their own quota. This is the storage-side counterpart of Federation’s per-peer compartmentalization and is the resolution of the federated-receive DoS.
  • Trash-retained assets count fully. An asset in trash still occupies storage until its retention window expires and it is hard-purged, so it counts against quota at full size. This is deliberate: it keeps accounting honest and gives users a concrete reason to empty trash rather than treating it as free overflow.
  • Derivatives are reclaimed on hard-purge. When an asset is hard-purged, its derivative and metadata blob references drop alongside the original’s; any blob whose reference count reaches zero is garbage-collected and the freed bytes are credited back to whichever user they were attributed to. A purged asset never leaves orphaned derivatives silently inflating a quota. The credit belongs to the sweep, not to the purge: a purge drops one asset’s references while the blob may still have others — two assets legitimately share a thumbnail — so refunding at purge time would give back bytes the server is still storing for the surviving holder. And because attribution is global by content address, the sweep asks the ledger who the bytes were charged to rather than assuming the deleting account: the blob may belong to somebody with no remaining connection to the asset whose purge exposed it.

A user account exists in one of these quota states:

StateThresholdBehavior
OKquota_used < soft_limitAll uploads succeed normally.
Soft warningsoft_limit ≤ quota_used < hard_limitUploads succeed, but the client warns — the quota_soft alert class. Quota state is server-held, so the warning is only as current as the last GET /v1/quota.
Hard exceededquota_used ≥ hard_limitNew uploads rejected at session creation with a structured error. Metadata edits and every other write still work; existing assets remain accessible.
Grace expiredquota_used ≥ hard_limit for > grace_window (default 14 days)Entering the grace window raises quota_grace_expiring (alert class). Adds to Hard-exceeded: all other metadata-growth writes (caption/tag edits, new share or upload links) are now refused too. Reads, deletes, and restore-from-trash still work — the provenance/metadata writes a delete, trash-restore, or trash-empty itself produces are always admitted (a user must be able to delete their way back under quota). Freeing space lifts it.
Suspended(admin or billing action — see Moderation)Server-defined; possibly upload refusal, possibly full lockout.

Defaults for soft_limit, hard_limit, and grace_window are deployment-configurable. Self-hosted servers might run with no quota (hard_limit = ∞); hosted services set per-tier limits.

Where the quota check actually runs:

  • At POST /v1/upload session creation. The server computes quota_used(upload_user_id) + declared_size and rejects with 403 Quota Exceeded (or similar structural code) if it crosses the hard limit. This is the only hard enforcement point — once a session is open, the declared size is the cap, and the session is allowed to complete.
  • At session cancellation. When a session is cancelled or expires, the reserved-but-uncommitted bytes are released; the next quota check sees the new (lower) usage.
  • At finalization. Cumulative size is bounded by the declared size at chunk acceptance; no separate quota check at finalization is needed because the declared size was already approved at session creation.
  • Staged uploads need no special case. Each tier’s session is charged at its own creation — the same single enforcement point, just later in time for the original’s session.
  • At metadata-update writes. A metadata-update creates a new encrypted metadata blob; the size delta is checked against quota. Tiny but non-zero.
  • Sponsored-account attribution. A sponsoree’s uploads count against the sponsor’s quota — the sponsoree’s upload_user_id derives from the sponsor (Keys — Delegated/Sponsored), so storage rolls up to the sponsoring (billing) account. There is no separate sponsoree quota.
  • Web-upload drops. A pending web-upload drop counts against the provisioning user’s quota — the link owner is the upload_user_id, charged at drop-session creation like any other session, so a leaked link cannot push storage past the owner’s hard limit. Adoption in place only reclassifies the already-stored blob from inbox to album asset, so the bulk bytes incur no new quota — the adoption’s small metadata and provenance blobs are charged like any metadata write; a discarded or expired drop frees its bytes on the next GC. There is no separate inbox quota.
  • Per-album quotas. Out of scope for v1 — quota is per upload_user_id only. A deployment that later wants per-album caps adds them as a second, independent check at the same enforcement point; the accounting model above does not change.
  • Streaming import. A storage-constrained streaming import creates one upload session per asset, so it is bounded by the same per-session check at creation — there is no new enforcement point. Quota exhaustion mid-stream refuses the next session; the pipeline pauses, and no local original is released for an asset that did not upload.
  • Grace-window UX. The structural rule is “upload session creation refused” in read-only mode; the client surfaces this as a discoverable, remediable state (what is full, what to delete) rather than an opaque mid-import error. Concrete copy is a client-UX detail.
  • Billing integration. Out of scope and deliberately decoupled: this doc owns accounting and enforcement (what quota_used is, where the check runs); a billing/tier system, where present, only sets soft_limit / hard_limit / grace_window. Self-hosted deployments run with no billing and hard_limit = ∞.
// planned in capsule-server::quota
struct QuotaStatus {
used: u64,
soft_limit: u64,
hard_limit: u64,
state: QuotaState, // OK | SoftWarning | HardExceeded | GraceExpired | Suspended
}
fn check_quota(user: UserId, additional_bytes: u64) -> Result<(), QuotaError>;
fn current_status(user: UserId) -> QuotaStatus;

Concrete error types, the GET /v1/quota response shape, and admin controls are an implementation detail; the accounting model and enforcement points above are the contract.

  • Hard-limit enforcement (unit). A session creation that would cross the hard limit is rejected with the right code; no pending row is written.
  • Dedup attribution (unit). Two users upload the same content; assert only the first user’s quota is debited.
  • Trash-retention accounting (unit). Soft-delete an asset; assert it still counts at full size until hard-purge; hard-purge it; assert the bytes are released.
  • Federated-receive accounting (unit). Cache a federated blob for a receiving user; assert it debits the receiver, deduped (a blob the server already holds is not double-counted); exhaust a (receiving_user, source_peer) caching budget; assert further pulls from that peer are refused.
  • Derivative reclaim on purge (unit). Hard-purge an asset; assert its derivative + metadata blob references drop and any zero-reference blob is GC’d, with bytes credited back — no orphaned derivative left counting.
  • Grace expiry (smoke). Mock the grace window past; assert read-only mode behavior.
  • Quota status reporting (unit). GET /v1/quota returns accurate used + state for a fixture user.