Quota
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 capsule-api-service::quota. Accounting reads from the Postgres asset index (size sums per upload_user_id); enforcement runs at session creation, before any chunks are accepted.
Accounting Model
Section titled “Accounting Model”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.
Thresholds and States
Section titled “Thresholds and States”A user account exists in one of these quota states:
| State | Threshold | Behavior |
|---|---|---|
| OK | quota_used < soft_limit | All uploads succeed normally. |
| Soft warning | soft_limit ≤ quota_used < hard_limit | Uploads succeed, but the UI surfaces a warning. |
| Hard exceeded | quota_used ≥ hard_limit | New uploads rejected at session creation with a structured error. Existing assets remain accessible. |
| Grace expired | quota_used ≥ hard_limit for > grace_window (default 14 days) | Read-only mode: reads, deletes, and restore-from-trash still work; only new uploads and metadata-growth writes are refused. Freeing space (emptying trash) 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.
Enforcement Points
Section titled “Enforcement Points”Where the quota check actually runs:
- At
POST /uploadsession creation. The server computesquota_used(upload_user_id) + declared_sizeand rejects with403 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.
- At metadata-update writes. A metadata-update creates a new encrypted metadata blob; the size delta is checked against quota. Tiny but non-zero.
Scope Decisions
Section titled “Scope Decisions”- Sponsored-account attribution. A sponsoree’s uploads count against the sponsor’s quota — the sponsoree’s
upload_user_idderives from the sponsor (Keys — Delegated/Sponsored), so storage rolls up to the sponsoring (billing) account. There is no separate sponsoree quota. - Per-album quotas. Out of scope for v1 — quota is per
upload_user_idonly. 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. - 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_usedis, where the check runs); a billing/tier system, where present, only setssoft_limit/hard_limit/grace_window. Self-hosted deployments run with no billing andhard_limit = ∞.
Contract Skeleton
Section titled “Contract Skeleton”// in capsule-api-service::quotastruct 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 /quota response shape, and admin controls are an implementation detail; the accounting model and enforcement points above are the contract.
Validation
Section titled “Validation”- 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 /quotareturns accurateused+statefor a fixture user.